Repository navigation
fix(agents): facet sub-agent no longer touches the root DO's WebSockets (#1677) - #1679
Merged
threepointone merged 1 commit intoJun 4, 2026
Merged
Conversation
…ts (#1677) A sub-agent (facet) that enumerated its connections could crash in production with: Cannot perform I/O on behalf of a different Durable Object. I/O objects (such as streams, request/response bodies, and others) created in the context of one Durable Object cannot be accessed from a different Durable Object. (I/O type: Native) This reproduced when the ROOT Agent held a live (hibernatable) WebSocket and a child facet was freshly bootstrapped while that socket was open. It never reproduced in `wrangler dev`/miniflare, which made it hard to catch. Root cause ---------- The `Agent` overrides of `getConnections()` and `getConnection()` fell through to `super.getConnections()` / `super.getConnection()` for facets too. On a facet, `super.getConnections()` -> `ctx.getWebSockets()` resolves to the HOST/root DO's hibernatable WebSockets. The connection-wrapping machinery then reads `readyState` on those host-owned sockets from the facet's I/O context, which workerd aborts as a cross-DO native I/O access. `setState()` / `broadcast()` only tripped this incidentally: `_broadcastProtocol()` enumerates connections (to build its exclude list) before sending anything, so the crash happened during iteration, not during any send. Exact runtime behavior (isolated against a raw, no-agents repro): - facet `ctx.getWebSockets()` -> returns the host's sockets (prod) / [] (local) - `ws.deserializeAttachment()` -> ok - `ws.readyState` -> throws (Native I/O) in production Only a facet FRESHLY bootstrapped while the host already holds the socket sees those sockets; a facet bootstrapped earlier (or reused) sees [] and is fine. Local dev (`wrangler dev`/miniflare) always returns [] from a facet's `getWebSockets()`, so the bug is production-only. Fix --- In `getConnections()` and `getConnection()`, when `this._isFacet`, return only the facet's virtual sub-agent connections and never fall through to `super.*`. A facet's client connections are all virtual (real sockets are owned by the root and bridged in), so a facet must never read the host DO's sockets. Delivery of facet state updates to clients connected directly to the sub-agent is unchanged. Tests ----- Adds regression tests in packages/agents/src/tests/sub-agent.test.ts (+ fixtures in tests/agents/sub-agent.ts): - a facet's getConnections() excludes the root's direct connections - a facet can setState() / broadcast() while the root holds a live connection - state updates are still delivered to the sub-agent's own client Note: the native crash is production-only (a facet's getWebSockets() returns [] under vitest-pool-workers/miniflare), so these are behavioral guards that assert the facet-vs-host connection boundary rather than reproducing the raw I/O abort. Repro ----- Adds wip/issue-1677-minimal: a tight, dependency-free (no `agents`) Durable Objects + facets reproduction for the workerd team. A `Child` facet reads `ctx.getWebSockets()[*].readyState` while the `Parent` holds a live WebSocket => 500 in production, 200 locally. README documents the prod-vs-local divergence and the exact failing primitive. Changeset: agents patch (.changeset/facet-getconnections-cross-do-io.md).
🦋 Changeset detectedLatest commit: 9118811 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
Merged
threepointone
added a commit
that referenced
this pull request
Jun 4, 2026
…rd (#1680) The `_suppressProtocolBroadcasts` flag was added in #1425 to stop `_broadcastProtocol()` from enumerating the parent DO's WebSockets during facet bootstrap (the cross-DO Native I/O crash, #1410/#1677). The proper fix in #1679 makes this unnecessary: on a facet, `getConnections()` returns only virtual sub-agent connections and `broadcast()` routes through the parent bridge, so `_broadcastProtocol()` can no longer touch the parent's own sockets — during bootstrap or otherwise. Removing the flag also lets legitimate state sync run during the bootstrap window. Verified in production (raw #1410 repro): with the flag removed, both the HTTP control path and the WebSocket path (spawn sub-agent from `onMessage`) return ok:true. Also removes the now-obsolete `wip/issue-1410-reporter-repro` fixture (issue fully resolved by #1679; verified in prod before removal). The separate request/WebSocket/email native-handle clearing from #1425 is retained. It is not what fixes #1410 — verified that a child facet never inherits the parent's connection across the facet RPC boundary even without it — but it is kept as low-cost, correct context hygiene for cross-Agent calls (covering request/email/general cases #1679 does not touch).
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.
Summary
Fixes #1677 — a sub-agent (facet) could crash in production with:
It reproduced when the root
Agentheld a live (hibernatable) WebSocket anda child facet was freshly bootstrapped while that socket was open. It never
reproduced in
wrangler dev/miniflare, which made it hard to catch.Root cause
The
Agentoverrides ofgetConnections()andgetConnection()fell through tosuper.getConnections()/super.getConnection()for facets too. On a facet,super.getConnections()→ctx.getWebSockets()resolves to the host/root DO'shibernatable WebSockets, and the connection-wrapping machinery reads
readyStateon those host-owned sockets from the facet's I/O context — a cross-DO native I/O
access that workerd aborts.
setState()/broadcast()only tripped this incidentally:_broadcastProtocol()enumerates connections (to compute its exclude list) before sending anything,
so the crash happened during iteration, not during a send.
Exact runtime behavior (isolated)
Measured against a raw, dependency-free repro (see below), from inside a facet
while the parent holds a live socket:
ctx.getWebSockets()[](local)ws.deserializeAttachment()ws.readyStateAdditional findings:
sees those sockets; a facet bootstrapped earlier (or reused) sees
[]and isfine.
wrangler dev/miniflare a facet'sgetWebSockets()returns
[](verified), so the bug is invisible locally.Fix
In
getConnections()andgetConnection(), whenthis._isFacet, return onlythe facet's virtual sub-agent connections and never fall through to
super.*. Afacet's client connections are all virtual (real sockets are owned by the root
and bridged in), so a facet must never read the host DO's sockets. Delivery of
facet state updates to clients connected directly to the sub-agent is unchanged.
Tests
Adds regression tests in
packages/agents/src/tests/sub-agent.test.ts(plusfixtures in
tests/agents/sub-agent.ts):getConnections()excludes the root's direct connectionssetState()/broadcast()while the root holds a live connectionReproduction artifact
Adds
wip/issue-1677-minimal/: a tight, no-agents-dependency DurableObjects + facets reproduction for the workerd team. A
Childfacet readsctx.getWebSockets()[*].readyStatewhile theParentholds a live WebSocket →500 in production, 200 locally. The README documents the prod-vs-local
divergence and the exact failing primitive.
Changeset
agents— patch (.changeset/facet-getconnections-cross-do-io.md).Test plan
npm run check(sherif + exports + format + lint + typecheck) — greennpm run testfor the sub-agent / state / routing / readonly suites — 174 passingwrangler devreturns[]from a facet'sgetWebSockets()