Skip to content

test(agents): regression tests for nested sub-agent WebSockets (#2026) - #2096

Merged
threepointone merged 3 commits into
cloudflare:mainfrom
AntoniTok:fix-2026-nested-facet-ws-routing
Sep 29, 2026
Merged

threepointone merged 3 commits into
cloudflare:mainfrom
AntoniTok:fix-2026-nested-facet-ws-routing

Conversation

@AntoniTok

@AntoniTok AntoniTok commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Closes #2026

The routing fix for #2026 landed on main in #2400 (descendants no longer receive the root's outer-URL header), after the WebSocket forwarding code moved into dynamic-agents.ts. This PR is now reduced to the regression tests from the original work, which pass on main:

  • Two nested facet hops: a WebSocket through /sub/spike-sub-child/{middle}/sub/spike-sub-child/{leaf} upgrades, round-trips a message, and stays open.
  • Leaf broadcast across two hops: a leaf's broadcast() (sync, so its caller can't await it) reaches the root-owned socket.

The original index.ts changes are dropped. Main tracks facet deliveries per stream in bridges.ts, so it doesn't need the agent-wide delivery drain, or the per-frame scoping and background-delivery test built on it. Test-only, so no changeset.


Devin Review

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

⚠️ No Changeset found

Latest commit: 5d34a44

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a 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.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

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: 5d34a44

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.
Co-authored-by: Cursor <cursoragent@cursor.com>
@threepointone threepointone changed the title fix(agents): route nested facet WebSockets without recursing test(agents): regression tests for nested sub-agent WebSockets (#2026) Sep 29, 2026
@threepointone

Copy link
Copy Markdown
Contributor

Thanks @AntoniTok. The same root cause was fixed on main in #2400 while this was open, after the forwarding code moved to dynamic-agents.ts. So I've reduced this PR to your regression tests, which pass on main, and dropped the index.ts changes; main's per-stream delivery tracking doesn't need the frame drain. Your diagnosis in the description was spot on.

@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.

Devin Review

Comment on lines +151 to +152
it("establishes a WebSocket through two nested facet hops", async () => {
// Regression for #2026. The root owns the physical socket and

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.

🔍 Description describes fixes already merged

The current diff adds only regression tests; the routing and delivery fixes described in the PR have already landed on the base branch. Update the description to reflect this PR's remaining scope.

Devin Review


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

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.

Done. The title and description now say the fix landed in #2400 and this PR adds only the two regression tests.

@agent-think

agent-think Bot commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

✅ agents import sizes: no significant changes (310c3433 → 5d34a448, workflow run)

@threepointone
threepointone merged commit 9bddf2c into cloudflare:main Sep 29, 2026
16 checks passed
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

2 participants