Skip to content

fix(agents): facet sub-agent no longer touches the root DO's WebSockets (#1677) - #1679

Merged
threepointone merged 1 commit into
mainfrom
fix/issue-1677-facet-getconnections-cross-do-io
Jun 4, 2026
Merged

threepointone merged 1 commit into
mainfrom
fix/issue-1677-facet-getconnections-cross-do-io

Conversation

@threepointone

@threepointone threepointone commented Jun 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #1677 — a sub-agent (facet) 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)

It 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, and the connection-wrapping machinery reads readyState
on 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:

operation on a host-owned socket result
ctx.getWebSockets() returns the host's socket (prod) / [] (local)
ws.deserializeAttachment() ok
ws.readyState throws (Native I/O) in production

Additional findings:

  • 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.
  • Production only — in wrangler dev/miniflare a facet's getWebSockets()
    returns [] (verified), so the bug is invisible locally.

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 (plus
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 asserting the facet-vs-host connection boundary rather than reproducing
the raw I/O abort.

Reproduction artifact

Adds wip/issue-1677-minimal/: a tight, no-agents-dependency 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. 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) — green
  • npm run test for the sub-agent / state / routing / readonly suites — 174 passing
  • Verified the fix on a deployed Worker (prod 500 → 200 with a root socket held)
  • Verified local wrangler dev returns [] from a facet's getWebSockets()

Open in Devin Review

…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-bot

changeset-bot Bot commented Jun 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9118811

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

This PR includes changesets to release 1 package
Name Type
agents 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: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 3 additional findings.

Open in Devin Review

@pkg-pr-new

pkg-pr-new Bot commented Jun 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

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

@cloudflare/ai-chat

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

@cloudflare/codemode

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

hono-agents

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

@cloudflare/shell

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

@cloudflare/think

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

@cloudflare/voice

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

@cloudflare/worker-bundler

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

commit: 9118811

@threepointone
threepointone merged commit c8d1d32 into main Jun 4, 2026
5 checks passed
@threepointone
threepointone deleted the fix/issue-1677-facet-getconnections-cross-do-io branch June 4, 2026 20:29
@github-actions github-actions Bot mentioned this pull request Jun 4, 2026
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).
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.

Cannot perform I/O on behalf of a different Durable Object.

1 participant