Skip to content

computerd: clean up RPC interfaces - #108

Merged
aron-cf merged 23 commits into
mainfrom
update-computerd-endpoint
Aug 18, 2026
Merged

computerd: clean up RPC interfaces#108
aron-cf merged 23 commits into
mainfrom
update-computerd-endpoint

Conversation

@aron-cf

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

Copy link
Copy Markdown
Collaborator

The daemon in the container served its RPC on two paths: /ws for a websocket, and /api for a transport that sends one HTTP request per call.

This PR makes the following changes:

  • Just one endpoint, /api, carrying a WebSocket, at both ends of the connection.
  • The per-call HTTP transport is removed. The capnweb batch HTTP transport doesn't currently support streaming, the server cannot callback to the client (See: writeup.md).
  • Starting a connection now defines the host address and supported paths removing the hardcoded /ws and /health endpoints from computerd.
  • UPSTREAM_URL and the sync loop have been removed to simplify computerd.
  • GET /api/watermarks reports sync revisions over plain HTTP for use in integration tests.
  • A request to /api without an upgrade header gets a 400. One asking for a websocket version the server does not speak gets a 426 and the versions it does.
  • The capnweb library is bumped from 0.8.0 to 0.10.0.

Example /connect request:

POST /connect
{ "base": "http://computer.internal", "health": "/health", "api": "/api" }
sequenceDiagram
    participant Host as Durable object
    participant Daemon as Daemon in container
    Host->>Daemon: POST /connect with base and both paths
    Daemon->>Host: poll base + health until it answers
    Daemon->>Host: open websocket at base + api
    Host->>Daemon: drive syncing over that connection
Loading

Important

This breaks hosts that route the upgrade themselves. A durable object matching /ws in its own fetch() handler has to match /api. I don't expect there to be any of these.

New tests cover the connect request refusing an incomplete body, the handshake statuses, and the revisions endpoint, plus one that points the daemon at deliberately unusual paths to prove it dials what it is told. Running the harnesses surfaced three faults in them, unrelated to this work and fixed alongside it. The daemon reference, the lifecycle notes, both readme files and the debugging notes now describe a single endpoint, and a claim that the container backend set UPSTREAM_URL automatically is corrected, since it never did.

@pkg-pr-new

pkg-pr-new Bot commented Aug 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 16eacf8

devin-ai-integration[bot]

This comment was marked as resolved.

@aron-cf
aron-cf force-pushed the update-computerd-endpoint branch from 813d07e to d0f04be Compare August 17, 2026 17:19
@changeset-bot

changeset-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

馃 Changeset detected

Latest commit: 16eacf8

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 Minor
@cloudflare/dofs Minor
@cloudflare/computer-rpc Minor
@cloudflare/computerd Minor

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

@aron-cf
aron-cf force-pushed the update-computerd-endpoint branch 2 times, most recently from f529cd3 to 89e24fe Compare August 18, 2026 10:24
aron-cf added 23 commits August 18, 2026 12:42
A caret range on a 0.x version admits only patch releases, so ^0.8.0
held the workspace at 0.8.x and installing could not move it. Both
declarations, in packages/rpc and packages/computer, now ask for
^0.10.0.

Of the releases in between, 0.9.1 fixed nodeHttpBatchRpcResponse
leaving the connection open and failing with ERR_HTTP_HEADERS_SENT on
a request that is not a POST, and 0.10.0 added receiver-side limits on
message size, nesting depth, and bigint length. The MessagePort wire
format changed in 0.9.0; nothing here opens one.

@cloudflare/sandbox pins capnweb at 0.8.0, so
examples/think-compare-runtimes, which imports both it and
@cloudflare/computer, resolves two copies where it previously shared
one. Each drives its own session and no stub crosses between them.
Setting UPSTREAM_URL made the daemon dial the given address as a client,
pull once before it started listening, and then run a sync tick every
250 milliseconds. It was the only path where the daemon drove its own
sync loop.

It predates POST /connect, which solved the same problem for hosts that
cannot reach into the container, and no deployment sets it: the container
backend passes PORT and MOUNT_POINT and nothing else. Sync is driven from
the host across the capnweb session.

Two rough edges go with it. An address that was wrong or not yet
listening made the process exit before the HTTP server bound, so /health
never answered and the operator got "WebSocket connection failed" and no
further detail. And once a healthy peer dropped the socket nothing
redialed, so the tick failed every 250 milliseconds while /health kept
answering ok, leaving a dead sync loop behind a healthy probe and a log
that grew without bound.

The upstream option on createNodeVirtualFileSystem goes too, along with
the polling loop and the stopSync handle it returned. Of the calls to
that function, all but one passed no argument; the exception was this
path. What is left has no opinion about syncing at all.
POST /connect used to accept a base URL and build two paths from it: the
base plus /health for the readiness poll, and the base plus /ws for the
dial. Both belong to the host, so the daemon was deciding routes it does
not serve, and renaming either one meant releasing a new binary
alongside the host change.

The request now names all three parts as base, health, and api. All are
required: defaulting health to /health and api to /ws would leave the
paths compiled in after all. A base must carry an http or https scheme,
and each path must be absolute, so toHttpUrl is gone and toWebSocketUrl
keeps normalizing the dial to ws or wss.

Both paths are rejected if they parse as an address of their own or
begin with a double slash. The daemon dials whatever it is handed, and
either form would resolve somewhere other than under the given base.
The daemon mounted one RPC object on two carriers: an HTTP batch handler
on POST /api and a websocket upgrade on /ws. It now serves the websocket
alone, on /api.

The batch route goes because it could not carry the interface. Every call
that reads data back returns a ReadableStream, and delivering one means
the server calls write() on a stub the client exposed and waits for an
acknowledgement. A batch session cannot complete a call from server to
client, so fetchChanges, fetchObjects, exec, and getExec all fail once
there is anything to deliver, while watermarks and hasObjects succeed. An
endpoint that answers the cheap calls and fails the rest is worse than
one that is absent.

A request that reaches /api without an Upgrade header now gets 400 and a
sentence saying a websocket is required. Handshakes that do try to
upgrade are answered by the ws package, except an unsupported
Sec-WebSocket-Version: it answers 400 where the specification calls for
426, so that case is handled before handing over and reports the versions
the server speaks.

Samplers that only want revision numbers have GET
/__computerd/watermarks, which reads through the same watermarks() the
wire serves so the two cannot drift.
The daemon served capnweb's HTTP batch transport through this wrapper and
no longer does, leaving it without a caller. Removing it also drops the
package's last use of capnweb's batch surface.
The container backend sent only the egress base and left the daemon to
append /health and /ws. The daemon now expects the request to name both
paths, so send them.

EGRESS_HEALTH_PATH and EGRESS_API_PATH sit next to DEFAULT_EGRESS_HOST
because they describe the same endpoint: the routes WorkspaceProxy
answers on the far side of the egress interceptor. Keeping them beside
the host name means a rename touches one place.
The daemon now serves its capnweb session on /api, and the host end of
the same connection follows, so both sides of the wire use one name.
WorkspaceProxy answers /api, the container backend routes /api upgrades
into the in-flight connect(), and the path travels to the daemon in the
/connect request rather than being assembled there.

The tokenized callback under /__workspace_connect keeps its existing
spelling. Nothing in this repository builds those URLs, so renaming that
segment would change an inbound contract without moving anything; both
forms normalize to /api before reaching the durable object.

handleFetch answered 426 for a request with no Upgrade header. That is a
malformed handshake, which is a 400. 426 belongs to the narrower case of
an unsupported Sec-WebSocket-Version, which is what the daemon now
answers for it, so both ends agree on what each status means.
The daemon and the host both serve the capnweb session on /api now, so
the durable objects that route the container's outbound upgrade follow.
Each example matched /ws in its fetch handler; the container example
forwards every request to the backend and needed no change beyond its
comments.
The daemon serves its session on /api. The FUSE flush check and the stub
soak both dial it directly, so both move with it.
The soak booted two daemons and pointed UPSTREAM_URL at the first, then
measured how fast it caught up. The second daemon was never the subject:
what the script measured was a daemon driving its own sync loop, and
driving a loop needs a peer to drive against. That mode is gone, and no
deployment used it.

A workspace pairs one host with one container, so the script now boots
one daemon and takes the host's part. It holds the authoritative store,
writes into it at the configured rate, and ticks pushOnce and pullOnce
across the capnweb session. The table reports the host revision beside
the daemon revision, and the gap between them is the convergence lag.

Resident memory still gets sampled every row, which is the other reason
this script exists. Revisions now come from GET /__computerd/watermarks
rather than an RPC session per sample, and SOAK_TICK_MS sets how often
the tick runs.
Bring the written surface in line with the daemon. It serves one
endpoint, /api, carrying one transport, and the HTTP batch alternative
is gone: every call that returns data returns a ReadableStream, and
delivering one needs a call from server to client that a batch session
cannot complete.

UPSTREAM_URL is removed from the environment tables and from the prose
that described a daemon-driven sync loop. The lifecycle document claimed
the Cloudflare backend wired that variable automatically, which was
never true; the carrier is POST /connect and the durable object drives
sync across it. That sentence is the one a reader lands on when working
out how a container recovers after a restart, so it pointed at the wrong
mechanism.

The /connect description now shows the request naming a base and both
paths, records the readiness probe reading base plus health, and covers
the new watermarks endpoint and the handshake statuses.
The tokenized connect callback matched /__workspace_connect/<id>/ws
while everything else moved to /api, leaving the proxy answering two
spellings for one endpoint. It now matches /api, so both the plain and
tokenized forms read the same and normalize to the same path before
reaching the durable object.

Nothing in this repository builds those URLs, so this changes an inbound
shape that only external callers can be using. A caller still requesting
the /ws form gets a 404 and needs the new segment.
The wire moved to /api. Two comments still named /ws as the path the
composite server is served on and the URL a client dials.
Two places still introduced the wire as a WebSocket "with an HTTP batch
alternative" while the paragraphs below them explained why there is no
such alternative. Say it once, in the opening, and give the reason:
capnweb's batch transport cannot deliver a stream returned from a call,
and every read on this interface returns one.
The endpoint moved to /api. This script embeds its client in a heredoc
rather than a .mjs file, so it was missed when the other harnesses were
updated.
The check wrote through FUSE to /workspace/x.txt and then read /x.txt
back from the receiver store, which cannot resolve: the virtual
filesystem keys entries by their absolute in-container path, so the
mount point is part of the key. Both ends now use one constant.

It also logged pullOnce's return as if it were a count. pullOnce
resolves to an ApplyResult, so the line printed "[object Object]
entries" and the guard comparing it against 0 could never fire.
Destructure applied.
The last section of exec-tests exercises Workspace.runtime through the
high-level facade, and it had never run. It imported TestBackend and
Workspace from per-file dist paths, which the bundler does not emit and
never has, so the script died on a module resolution error after the
seven wire-level checks passed.

Those symbols live on the package's main entry, which cannot be imported
under plain node: it re-exports WorkspaceProxy, extending
WorkerEntrypoint from cloudflare:workers, and that specifier only
resolves inside workerd. A node --import hook now registers a resolve
hook for the specifier and answers with stub classes, which is enough to
satisfy `class X extends Y` at module evaluation time. Nothing in the
stub is ever constructed.

The alternative was adding build entries for each file the script wants,
which would duplicate bundled code in a published package to suit a
harness.

Also correct the soak entry in the script list, which still described the
two-container topology.
The carrier entry stated that no HTTP alternative exists and then
explained why. A contract document is better describing what the wire
is than cataloguing what it is not, and the claim reads as though it
were about capnweb, which does ship an HTTP batch transport.
The write loop passed writeSeq++ for the file index and writeSeq for the
payload seed. Arguments evaluate left to right, so the increment landed
before the seed was read: every file carried the bytes generated for the
next index, and seed zero was never used.

Nothing in the script asserts on content, so the soak still measured what
it was built to measure. The pairing is wrong for anyone who later checks
the bytes against the name.
The endpoint rename, the /connect payload change, and the removal of
UPSTREAM_URL landed as a single entry. They are three separate things a
consumer may care about, and a reader scanning the changelog for the one
that affects them should not have to unpick a paragraph covering all
three.
The route read sync revisions through rpc.sync.watermarks() but sat at
/__computerd/watermarks, alongside runtime info and process memory. Those
report on the daemon; this reports the workspace, and it returns exactly
what a session would.

It is now /api/watermarks, which draws the line where it belongs: /api is
the workspace surface, including anything that reads through it, and
/__computerd is daemon introspection.

Only the exact /api path upgrades, so a handshake aimed at the subpath
still answers 404, and the shared secret covers the new path the same way
it covered the old one.
@aron-cf
aron-cf force-pushed the update-computerd-endpoint branch from be297d4 to 16eacf8 Compare August 18, 2026 13:39
@aron-cf
aron-cf merged commit e465077 into main Aug 18, 2026
19 checks passed
@aron-cf
aron-cf deleted the update-computerd-endpoint branch August 18, 2026 15:01
@github-actions github-actions Bot mentioned this pull request Aug 18, 2026
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