Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/facet-getconnections-cross-do-io.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"agents": patch
---

fix(sub-agents): a facet sub-agent no longer touches the root DO's WebSockets, fixing a production-only "Cannot perform I/O on behalf of a different Durable Object (Native)" crash (#1677)

A sub-agent (facet) that called `setState()`, `broadcast()`, or otherwise enumerated connections — directly or indirectly via the internal `_broadcastProtocol()` — could crash in production with `Cannot perform I/O on behalf of a different Durable Object. ... (I/O type: Native)`. It reproduced when the **root** Agent held a live (hibernatable) WebSocket connection and the child facet was freshly bootstrapped; 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, that resolves to the **host/root DO's** hibernatable WebSockets, and reading their attachments from the facet's I/O context is a cross-DO native I/O access that workerd aborts. `setState()` tripped it only incidentally, because `_broadcastProtocol()` enumerates connections to compute its exclude list before sending anything.

Fix: a facet's client connections are all virtual (real sockets owned by the root and bridged in), so `getConnections()`/`getConnection()` now return only the facet's virtual sub-agent connections and never fall through to the host DO's sockets. Delivery of facet state updates to clients connected directly to the sub-agent is unchanged.
12 changes: 12 additions & 0 deletions packages/agents/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6138,6 +6138,10 @@ export class Agent<
stored.meta
) as Connection<TState>;
}
// Do NOT fall through to `super.getConnection()` on a facet — it resolves
// to the host/root DO's hibernatable sockets and reading them from the
// facet's I/O context throws a cross-DO Native I/O error. See issue #1677.
return undefined;
}

const connection = super.getConnection<TState>(id);
Expand All @@ -6151,6 +6155,13 @@ export class Agent<
tag?: string
): Iterable<Connection<TState>> {
if (this._isFacet) {
// A facet's client connections are all virtual — they are real
// WebSockets owned by the ROOT DO and bridged in. We must NOT fall
// through to `super.getConnections()` here: on a facet that resolves to
// the host/root DO's hibernatable sockets, and reading their attachments
// from the facet's I/O context throws
// "Cannot perform I/O on behalf of a different Durable Object (Native)".
// See issue #1677.
for (const stored of this._cf_virtualSubAgentConnections.values()) {
if (!tag || stored.meta.tags.includes(tag)) {
yield this._cf_createSubAgentBridgeConnection(
Expand All @@ -6159,6 +6170,7 @@ export class Agent<
) as Connection<TState>;
}
}
return;
}

for (const connection of super.getConnections<TState>(tag)) {
Expand Down
54 changes: 54 additions & 0 deletions packages/agents/src/tests/agents/sub-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,28 @@ export class BroadcastSubAgent extends Agent<Cloudflare.Env, BroadcastState> {
return this.state.lastMsg;
}

/**
* Returns the ids of the connections this facet sees via
* `this.getConnections()`. A facet must only ever see its own (virtual)
* sub-agent connections — never the ROOT DO's direct connections. Touching
* the root's hibernatable WebSockets from a facet's I/O context throws
* "Cannot perform I/O on behalf of a different Durable Object (Native)" in
* production. See issue #1677.
*/
connectionIds(): string[] {
return [...this.getConnections()].map((connection) => connection.id);
}

/** Count of connections visible to this facet (see `connectionIds`). */
connectionCount(): number {
return [...this.getConnections()].length;
}

/** Resolve a connection by id through the facet's `getConnection()`. */
hasConnection(id: string): boolean {
return this.getConnection(id) !== undefined;
}

/**
* A dummy onStart observation: the base Agent's wrapped `onStart`
* calls `broadcastMcpServers()` before the user's `onStart` runs.
Expand Down Expand Up @@ -1810,6 +1832,38 @@ export class TestSubAgentParent extends Agent {
return child.initializedOk();
}

/** Count of connections the ROOT itself sees (its own direct connections). */
connectionCount(): number {
return [...this.getConnections()].length;
}

/** Connection ids visible to a freshly-resolved facet child (issue #1677). */
async subAgentConnectionIds(subAgentName: string): Promise<string[]> {
const child = await this.subAgent(BroadcastSubAgent, subAgentName);
return child.connectionIds();
}

/** Connection count visible to a freshly-resolved facet child (#1677). */
async subAgentConnectionCount(subAgentName: string): Promise<number> {
const child = await this.subAgent(BroadcastSubAgent, subAgentName);
return child.connectionCount();
}

/** Whether a facet child resolves the ROOT's connection id (#1677). */
async subAgentHasConnection(
subAgentName: string,
connectionId: string
): Promise<boolean> {
const child = await this.subAgent(BroadcastSubAgent, subAgentName);
return child.hasConnection(connectionId);
}

/** A connection id the ROOT currently sees, or null. */
firstConnectionId(): string | null {
const [connection] = [...this.getConnections()];
return connection ? connection.id : null;
}

// ── parentPath / registry exposure for Phase-1 tests ──────────────

async subAgentParentPath(
Expand Down
110 changes: 110 additions & 0 deletions packages/agents/src/tests/sub-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1562,6 +1562,116 @@ describe("SubAgent", () => {
ws.close();
}
});

// ── issue #1677: a facet must never touch the ROOT DO's sockets ──
//
// In production, a facet's `getConnections()`/`getConnection()` falling
// through to `super.*` resolves to the HOST/ROOT DO's hibernatable
// WebSockets; reading them from the facet's I/O context throws
// "Cannot perform I/O on behalf of a different Durable Object (Native)".
// These tests assert the behavioral invariant (a facet only sees its own
// virtual connections) which is what prevents that crash. Note the native
// crash itself only manifests on deployed workerd, not in miniflare, so
// these guard the invariant rather than reproducing the raw I/O error.

it("a facet's getConnections() excludes the ROOT's direct connections", async () => {
const parentName = uniqueName();
const childName = uniqueName();
// ROOT holds a live direct WebSocket connection.
const ws = await connectWS(`/agents/test-sub-agent-parent/${parentName}`);
try {
const parent = await getAgentByName(env.TestSubAgentParent, parentName);

// Sanity: the ROOT itself sees its own direct connection.
expect(await parent.connectionCount()).toBeGreaterThanOrEqual(1);
const rootConnId = await parent.firstConnectionId();
expect(rootConnId).not.toBeNull();

// The facet must NOT see the root's connection (and must not throw).
const ids = await parent.subAgentConnectionIds(childName);
expect(ids).toEqual([]);
expect(await parent.subAgentConnectionCount(childName)).toBe(0);

// getConnection(rootConnId) on the facet must not resolve the root's
// connection either.
expect(
await parent.subAgentHasConnection(childName, rootConnId as string)
).toBe(false);
} finally {
ws.close();
}
});

it("a facet can setState while the ROOT holds a live connection", async () => {
const parentName = uniqueName();
const childName = uniqueName();
const ws = await connectWS(`/agents/test-sub-agent-parent/${parentName}`);
try {
const parent = await getAgentByName(env.TestSubAgentParent, parentName);
const result = await parent.subAgentTrySetState(childName, 7, "io");
expect(result.error).toBe("");
expect(result.persistedCount).toBe(7);
expect(result.persistedMsg).toBe("io");
} finally {
ws.close();
}
});

it("a facet can broadcast while the ROOT holds a live connection", async () => {
const parentName = uniqueName();
const childName = uniqueName();
const ws = await connectWS(`/agents/test-sub-agent-parent/${parentName}`);
try {
const parent = await getAgentByName(env.TestSubAgentParent, parentName);
const error = await parent.subAgentTryBroadcast(childName, "hi");
expect(error).toBe("");
} finally {
ws.close();
}
});

it("delivers facet state updates to the sub-agent's own client even when the ROOT also holds a connection", async () => {
const parentName = uniqueName();
const childName = uniqueName();
// ROOT direct connection AND a client connected directly to the facet.
const rootWs = await connectWS(
`/agents/test-sub-agent-parent/${parentName}`
);
const childWs = await connectWS(
`/agents/test-sub-agent-parent/${parentName}/sub/broadcast-sub-agent/${childName}`
);
try {
await waitForJsonMessage<{
type: MessageType;
state?: { count: number; lastMsg: string };
}>(
childWs,
(data) =>
data.type === MessageType.CF_AGENT_STATE && data.state?.count === 0
);

const stateUpdatePromise = waitForJsonMessage<{
type: MessageType;
state?: { count: number; lastMsg: string };
}>(
childWs,
(data) =>
data.type === MessageType.CF_AGENT_STATE && data.state?.count === 99
);

const parent = await getAgentByName(env.TestSubAgentParent, parentName);
const result = await parent.subAgentTrySetState(childName, 99, "both");
expect(result.error).toBe("");

// The facet's own client still receives the broadcast — virtual
// connections are preserved by the #1677 fix.
const update = await stateUpdatePromise;
expect(update.state).toEqual({ count: 99, lastMsg: "both" });
} finally {
rootWs.close();
childWs.close();
}
});
});

// ── parentPath / selfPath / hasSubAgent / listSubAgents ────────────
Expand Down
90 changes: 90 additions & 0 deletions wip/issue-1677-minimal/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# issue-1677-minimal — raw workerd facet + WebSocket repro

Dependency-free reproduction of #1677
(`Cannot perform I/O on behalf of a different Durable Object … (I/O type: Native)`).
**No `agents` dependency** — just `cloudflare:workers` Durable Objects + facets.
This is the tightest, pure-workerd artifact for the runtime team.

## The bug in one sentence

On a **facet**, `this.ctx.getWebSockets()` returns the **host (parent) DO's**
hibernatable WebSockets, and reading `readyState` on one of those host-owned
sockets from the facet's I/O context aborts with `Cannot perform I/O … (Native)`.

## ⚠️ Production only — does NOT reproduce in local dev

This bug **only manifests on deployed Workers**. The divergence is in what a
facet's `ctx.getWebSockets()` returns:

| environment | facet `getWebSockets()` (parent socket held) | `/spawn` result |
| ------------------------------------ | -------------------------------------------- | ------------------------- |
| **local** (`wrangler dev`/miniflare) | `[]` — `count=0` (host sockets not exposed) | **200**, no crash |
| **production** (deployed) | the host's socket — `count=1` | **500**, Native I/O abort |

So in `wrangler dev` the facet never sees the parent's sockets, there is nothing
to read `readyState` on, and everything returns `200` — the bug is invisible
locally. You **must deploy** to reproduce. (Both behaviors verified empirically
against this repro.)

## Exactly which call throws (isolated)

With the parent holding a live WebSocket, from inside the `Child` facet:

| operation on a host-owned socket | result |
| ------------------------------------------------------------- | ----------------------- |
| `ctx.getWebSockets()` (returns the host's sockets, count = 1) | ok |
| `ws.deserializeAttachment()` | ok |
| **`ws.readyState`** | **throws (Native I/O)** |

`getWebSockets()` and `deserializeAttachment()` are fine; only `readyState` (a
native getter) trips workerd's cross-DO ownership check.

## Trigger (remove any → clean 200)

1. The `Parent` DO holds a live (hibernatable) WebSocket (`ctx.acceptWebSocket`).
2. The `Child` **facet** is **freshly bootstrapped while the parent already
holds that socket** — a facet bootstrapped earlier sees `[]` from
`getWebSockets()`, so reuse does NOT reproduce (each `/spawn` uses a fresh
facet name for this reason).
3. The facet reads `readyState` on the parent's socket via
`this.ctx.getWebSockets()`.

(Production only — see the section above for local-dev behavior.)

## Run

```bash
npx wrangler deploy --config wip/issue-1677-minimal/wrangler.jsonc
```

```bash
node -e '
const BASE="https://<worker>.<subdomain>.workers.dev", WS=BASE.replace("https://","wss://");
const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
const spawn=(p)=>fetch(`${BASE}/spawn?parent=${p}`).then(r=>r.status);
(async()=>{
// baseline: parent has no socket -> facet sees 0 sockets -> 200
console.log("no socket:", await spawn("p-"+Date.now()));
// hold a parent socket open, then bootstrap a facet that reads readyState
const p="p-"+crypto.randomUUID().slice(0,8);
const ws=new WebSocket(`${WS}/ws?parent=${p}`);
ws.onopen=async()=>{ await sleep(1200); console.log("with socket:", await spawn(p)); process.exit(0); };
})();'
```

Expected:

```
no socket: 200 (count=0 readyStates=[])
with socket: 500 (Cannot perform I/O … Native)
```

## Relationship to the Agents SDK (#1677)

The Agents SDK hit this because a facet sub-agent's `getConnections()` fell
through to `super.getConnections()` → `ctx.getWebSockets()`, and the connection
machinery reads `readyState`. `setState()`/`broadcast()` reached it via
`_broadcastProtocol()`, which enumerates connections before sending. Fixed in
`packages/agents/src/index.ts` (a facet now returns only its virtual sub-agent
connections and never touches the host DO's sockets); see the changeset
`facet-getconnections-cross-do-io.md`.
13 changes: 13 additions & 0 deletions wip/issue-1677-minimal/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types env.d.ts --include-runtime false` (hash: ceb47556f45c7e915398d74f3b5267bb)
interface __BaseEnv_Env {
Parent: DurableObjectNamespace<import("./src/index").Parent>;
}
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./src/index");
durableNamespaces: "Parent" | "Child";
}
interface Env extends __BaseEnv_Env {}
}
interface Env extends __BaseEnv_Env {}
17 changes: 17 additions & 0 deletions wip/issue-1677-minimal/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "facet-ws-native-io-minimal-1677",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"types": "wrangler types env.d.ts --include-runtime false",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260604.1",
"typescript": "^6.0.3",
"wrangler": "^4.97.0"
}
}
Loading
Loading