Skip to content

fix(agents): route nested facet WebSockets without recursing - #2096

Open
AntoniTok wants to merge 2 commits into
cloudflare:mainfrom
AntoniTok:fix-2026-nested-facet-ws-routing
Open

fix(agents): route nested facet WebSockets without recursing#2096
AntoniTok wants to merge 2 commits into
cloudflare:mainfrom
AntoniTok:fix-2026-nested-facet-ws-routing

Conversation

@AntoniTok

Copy link
Copy Markdown
Contributor

Closes #2026

The problem

A WebSocket two or more hops deep (/sub/{class}/{name}/sub/{class}/{name}) upgraded with HTTP 101 and then immediately died with code 1011. One hop was fine.

Only the root agent owns the real socket. It remembers the whole route in a private header, x-cf-agents-subagent-url, and strips one /sub/{class}/{name} hop each time it passes the connection down. But it also copied every header down — including that private one.

So the leaf ended up holding a route written from the root's point of view. It read the first hop in it, saw middle, and concluded middle must be one of its own children. middle is its grandparent. It created a new facet, which read the same note, and so on until workerd stopped it:

Facet nesting depth limit exceeded. The maximum depth including the root Durable Object is 4.

One hop only passed by accident: the single-hop self-strip in _cf_resolveSubAgentConnection happened to consume the leaf's own segment, leaving nothing to route.

What changed

Descendants no longer receive the header, and no longer read it even if they somehow get one:

-        requestHeaders: forwardReq ? [...forwardReq.headers] : undefined
+        requestHeaders: forwardReq
+          ? this._cf_getForwardedSubAgentHeaders(forwardReq)
+          : undefined
-    const outerUri = this._unsafe_getConnectionFlag(
-      connection,
-      CF_SUB_AGENT_OUTER_URL_KEY
-    );
+    const outerUri = this._isFacet
+      ? undefined
+      : this._unsafe_getConnectionFlag(connection, CF_SUB_AGENT_OUTER_URL_KEY);

A facet's connection URI is already stripped one hop per level, so routing from it is correct at any depth. The invariant — only the root may hold this route — is now written down at the declaration. _cf_getForwardedSubAgentState already stripped the same key from forwarded state; the header channel was the remaining hole.

The second defect

Fixing the routing exposed a bug underneath it. The nested socket now reached the leaf, but the reply never came back:

Error: RPC stub used after being disposed.

Each hop hands the next one a SubAgentConnectionBridge, an RpcTarget whose stub is disposed the moment its inbound call returns. Every delivery was fire-and-forget:

send(message) { void getStored().bridge.send(message); }

The bridge is effectively on loan for the duration of the call. Returning without waiting means hanging up while your reply is still travelling:

Browser sends "hello":

1. ROOT gets "hello" from the browser
2. ROOT calls MIDDLE, lending it bridge-A   (to reply to Root)
3. MIDDLE calls LEAF, lending it bridge-B   (to reply to Middle)
4. LEAF runs user code: connection.send("pong")
      -> starts sending "pong" over bridge-B
5. LEAF does not wait for it to arrive. LEAF returns.
6. MIDDLE sees LEAF is done, so bridge-B is disposed
7. "pong" was still in flight on bridge-B
      -> severed -> "RPC stub used after being disposed"

Step 7 is only the first place this is visible, not the only place it is wrong. Every link had the same flaw: if the reply had survived step 7, Middle would then have sent it over bridge-A and returned without waiting, and Root would have disposed bridge-A out from under it. The message dies at whichever link loses the race first.

That is also why one hop passed. With no middle there is a single link and a single short trip, so the send usually landed before the bridge was reclaimed. Two hops means winning two races instead of one, and it lost consistently. Depth 1 was never correct, only lucky.

Deliveries are now awaited at each hop, so no agent finishes while it is still holding someone else's message:

-  send(message: string | ArrayBuffer | ArrayBufferView): void {
+  async send(message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
     this.#connection.send(message);
+    await this.#flush?.();
   }

Connection.send and broadcast are synchronous by contract, so their promises have nowhere to be returned. Those are recorded instead, and drained before the frame's RPC returns. Deliveries flow strictly rootward, so the drain cannot cycle; on the root it is a no-op, since a real socket sends natively.

I kept these together because #2026's expected behaviour — the client receiving pong:{leaf}:hello — is unreachable with only the routing fix, and the delivery bug is unobservable without it.

Tests

Two additions to src/tests/spike-sub-agent-routing.test.ts, using the existing SpikeSubParent/SpikeSubChild fixtures:

  • two nested facet hops — the repro from the issue. Fails on main with the depth-limit error.
  • leaf broadcast across two hops — covers broadcast(), the sync-contract path that cannot await itself. Also fails on main.

Verified red→green rather than assumed: with only the header fix applied, the first test still fails with RPC stub used after being disposed. That is the evidence the delivery fix is load-bearing rather than speculative.

Verification

  • pnpm run test:workers — 90 files / 1803 tests
  • pnpm run check — sherif, export check, oxfmt, oxlint, 121 projects typecheck
  • @cloudflare/ai-chat 50/737, @cloudflare/voice 12/227, @cloudflare/think 2/5
  • nx affected -t test — 16 projects

AIChatAgent and Think both wrap onConnect/onMessage and branch on _cf_connectionTargetsSubAgent, which reads connection.uri. That semantics is unchanged, and their suites pass.

Not addressed

A sub-agent route two or more hops deep upgraded with HTTP 101 and then
closed with 1011. The root's private route header was copied into every
descendant's forwarded request, so a leaf read its own ancestor as one of
its children and created facets recursively until workerd rejected the
chain at the depth limit. One hop passed only because the single-hop
self-strip happened to consume the leaf's own segment.

Descendants now route from their already-stripped connection URI, and the
header is dropped when forwarding. Fixing the routing exposed a second
defect on the reply path: each hop's bridge is an RpcTarget whose stub is
disposed when its inbound call returns, and every hop was
fire-and-forget, so with two hops the inner delivery was cut off with
"RPC stub used after being disposed". Deliveries are now awaited at each
hop, with the sync `send`/`broadcast` contracts tracked and drained
before the frame returns.
@changeset-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8e18b89

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
agents Patch
@cloudflare/agent-think Patch

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread packages/agents/src/index.ts Outdated
Comment on lines +7410 to +7422
private async _cf_drainSubAgentConnection(id: string): Promise<void> {
const awaited = new Set<Promise<unknown>>();
// Terminates because each pass only awaits promises it has not seen,
// and a frame can only queue finitely many.
while (true) {
const inFlight = [
...(this._cf_virtualSubAgentConnections.get(id)?.pending ?? []),
...this._cf_pendingSubAgentDeliveries
].filter((promise) => !awaited.has(promise));
if (inFlight.length === 0) return;
for (const promise of inFlight) awaited.add(promise);
await Promise.allSettled(inFlight);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A sub-agent's message handling can stay open indefinitely when the agent keeps sending in the background

Every incoming frame now waits for all of the agent's outstanding outgoing messages, including ones belonging to other clients or to background work (_cf_drainSubAgentConnection at packages/agents/src/index.ts:7410-7422), and the wait restarts whenever a new outgoing message appears, so handling a single frame can stay open for as long as the agent keeps sending.
Impact: A sub-agent that streams or periodically pushes messages (e.g. a chat agent emitting chunks) keeps the frame's call open for the whole stream, and one slow client's delivery can hold up an unrelated client's frame.

Why the drain loop's termination assumption does not hold

The loop mixes two sources: the per-connection pending set and the agent-wide _cf_pendingSubAgentDeliveries set, which is populated by broadcast() on a facet (packages/agents/src/index.ts:7091-7093) regardless of which connection or which turn started it.

The inline comment claims termination because "a frame can only queue finitely many" deliveries. That holds only if the deliveries are all started by the frame being drained. In practice the agent-wide set is also fed by background/streaming code (e.g. AIChatAgent._broadcastChatMessage emitting chunks after onMessage returns) and by concurrent frames on other connections. Each loop pass re-reads both sets and filters out only promises it has already awaited, so a steady supply of new broadcasts keeps inFlight non-empty and the loop keeps going.

The callers are the finally blocks of _cf_handleSubAgentWebSocketConnect (packages/agents/src/index.ts:7559-7564), _cf_handleSubAgentWebSocketMessage (:7609-7615) and _cf_handleSubAgentWebSocketClose (:7627-7634), plus SubAgentConnectionBridge's #flush on every send/close/setState, so the coupling applies to each hop.

Scoping the drain to the deliveries actually started by the current frame (for example by recording the connection id alongside each tracked broadcast, and snapshotting the set at frame entry) would keep the #2026 fix while bounding the wait.

Prompt for agents
_cf_drainSubAgentConnection in packages/agents/src/index.ts waits on two sources: the per-connection `pending` set of the virtual sub-agent connection, and the agent-wide `_cf_pendingSubAgentDeliveries` set fed by Agent.broadcast() on a facet. The loop re-reads both sets each pass and only skips promises it has already awaited, so it terminates only if no new deliveries keep appearing. The comment asserts a frame can only queue finitely many deliveries, but the agent-wide set is also fed by background work (streaming chat chunks broadcast after onMessage returns) and by concurrent frames on other connections. As a result the finally-block drains in _cf_handleSubAgentWebSocketConnect/Message/Close, and the #flush awaited inside SubAgentConnectionBridge.send/close/setState, can stay pending for the whole duration of an unrelated stream, keeping the inbound RPC (and the lent bridge stub) open far longer than intended and coupling unrelated connections. Consider scoping tracked deliveries to the frame/connection that started them — e.g. record the connection id (or a per-frame token) when tracking a broadcast, and have the drain only await deliveries belonging to the frame being completed, or snapshot the pending set at frame entry rather than re-reading it in a loop.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this was a real bug and the termination comment was wrong. Fixed in 8e18b89.

You were right on both counts. _cf_pendingSubAgentDeliveries was agent-wide, and the loop re-read it every pass, so any steady source of broadcasts kept inFlight non-empty. My "a frame can only queue finitely many" claim only held for the per-connection set, not the shared one.

Two changes:

Deliveries from a sync API are now collected per frame. _cf_runSubAgentFrame installs a fresh sink around each connect/message/close handler and drains only that. The shared set is gone, so unrelated work cannot be in scope by construction.

A broadcast started outside a frame is no longer tracked at all. That turned out to be the more useful observation: those travel via _rootAlarmOwner() on a stub the agent owns, not on a borrowed bridge, so they were never exposed to the disposal race this PR is about. Waiting on them was pure cost. This covers your AIChatAgent._broadcastChatMessage case — chunks emitted after onMessage returns take the owned-stub path.

The drain snapshots once instead of polling. send and broadcast register synchronously, so everything a frame started is already present when it returns, and a completing delivery never queues another. Removes the loop entirely.

Also added a regression test. Worth noting how it observes the problem, because my first attempt was wrong: asserting that the echo still arrives passes either way, since the message is delivered before the drain. It is frame completion that stalls. So the test stalls a background delivery, closes the socket, and asserts the facet drops the connection — _cf_handleSubAgentWebSocketClose only deletes the entry once its frame finishes. Verified it hangs and fails against the agent-wide version, and passes with the fix.

pnpm run test:workers 90 files / 1804 tests, pnpm run check clean across 121 projects.

@pkg-pr-new

pkg-pr-new Bot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2096

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2096

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2096

hono-agents

npm i https://pkg.pr.new/hono-agents@2096

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2096

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2096

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2096

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2096

commit: 8e18b89

Review feedback on cloudflare#2096: deliveries were tracked in one agent-wide set,
and the drain re-read it each pass. Background broadcasts and concurrent
frames on other connections therefore kept it non-empty, so a frame could
stay open for the length of an unrelated stream and one connection could
stall another. The termination comment was wrong for the same reason.

Deliveries started from a sync API are now collected per frame, and the
drain snapshots once instead of polling. A broadcast started outside a
frame is left untracked: it travels on a stub the agent owns rather than
a borrowed bridge, so it was never exposed to the disposal race.

Adds a regression test that stalls a background delivery and asserts the
close frame still completes, observed through the facet dropping the
connection. It hangs against the agent-wide version.
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.

Nested facet WebSocket routes recurse until the facet depth limit is exceeded

1 participant