cloudflare / cloudflare/workerd

🐛 Bug Report — Runtime APIs: service-binding RPC dispatch hangs on second back-to-back call to WorkerEntrypoint

Open
#6,782 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
8.7k
Forks
739
Avg merge
2d 20h
Merged PRs (30d)
174

Description

## Summary

In a single Durable Object method, the **second** back-to-back call to a
`WorkerEntrypoint` RPC method over a service binding (`env.MY_BINDING.someMethod(...)`)
returns a Promise that **never settles** — it neither resolves nor rejects.

The first call returns normally (~200ms). The second hangs until the
caller-side timeout fires. The hang is independent of:

- the RPC method name,
- the argument shape,
- the response size,
- whether the first call completed before the second was issued.

Switching the transport on the same service binding to fetch
(`env.MY_BINDING.fetch(new Request(...))`) makes the bug disappear — both
back-to-back calls return cleanly. So this is specific to the **RPC
dispatch path**, not the service binding itself.

## Environment

- `workerd` `1.20260504.1` (`2026-04-29`)
- `compatibility_flags`: `["nodejs_compat"]`
- Paid Workers plan, single Cloudflare account, single Worker fronting the binding
- Caller is a Durable Object method (no Workflows, no special harness)

## Reproduction

Caller worker has a service binding pointed at a target `WorkerEntrypoint`
named `TargetEntrypoint`. Inside a DO method on the caller side, make two
back-to-back RPC calls:

```ts
// Inside a DO method on the caller worker
async probeRpcs(ctx: { entraOid: string }) {
const binding = this.env.MY_BINDING; // service binding to TargetEntrypoint

// Call #0 — returns normally
const a = await binding.callTool(
{ entraOid: ctx.entraOid },
{ name: "get_recent_meetings", arguments: {} }
);

// Call #1 — Promise never settles (no resolve, no reject)
const b = await binding.callTool(
{ entraOid: ctx.entraOid },
{ name: "get_recent_meetings", arguments: {} }
);

return { a, b };
}
```

The `TargetEntrypoint` side is a stock `WorkerEntrypoint` subclass:

```ts
import { WorkerEntrypoint } from "cloudflare:workers";

export class TargetEntrypoint extends WorkerEntrypoint {
async callTool(context: { entraOid: string }, request: { name: string; arguments?: unknown }) {
// ... real work; returns { content: [{ type: "text", text: "..." }] } in ~200ms
}
}
```

## Transport matrix (same service binding, same target, two back-to-back calls)

| Transport | Call #0 | Call #1 |
|---|---|---|
| `binding.callTool({entraOid}, {name, arguments})` (RPC entrypoint) | ✅ ~193ms | ❌ 20000ms caller timeout, Promise never settles |
| `binding.fetch(new Request("https://target/mcp", { method: "GET" }))` (default handler via service binding) | ✅ ~121ms | ✅ ~0ms |
| `fetch("https://target.example.workers.dev/mcp", { headers: { ... } })` (public URL) | ✅ ~238ms | ✅ ~11ms |

(The 4xx/5xx on the fetch variants are downstream of the transport — they hit an OAuth-protected route with no bearer, just to verify request/response flow. With a route purpose-built for the use case they'd be 200s.)

## Expected behavior

`binding.callTool(...)` on call #1 either resolves with the method's return value or rejects with an error, same as call #0.

## Actual behavior

The Promise returned by call #1 never settles. `.then` and `.catch` are never invoked. Diagnostic logging on the caller side confirms the Promise is dispatched but no resolution/rejection callback ever fires.

## Workaround

Exposing the same logic as an HTTP route on the `WorkerEntrypoint`'s `fetch` method and switching the caller from `binding.callTool(...)` to `binding.fetch(new Request(...))` over the same service binding eliminates the hang. Trust boundary is preserved (service-binding-only routing).

## Notes

- Single-tenant CF account, paid plan.
- Reproduces 100% of the time across attempts.
- Same shape works fine when invoked via the public hostname or via the binding's default fetch handler — only the RPC dispatch path to a `WorkerEntrypoint` method exhibits the hang.
- Happy to share a minimal standalone repro repo on request.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.