Skip to content

🐛 Bug: cross-Worker invocation of a [restore]()-backed persistent stub hangs at 0 CPU #6854

Description

@ndisidore

Summary

A persistent stub created via ctx.restore(params) in a Durable Object, stored in DO
storage, then returned out to a different Worker and invoked there, does not make progress:
the invocation sits at 0 CPU until the runtime cancels it. The target's [restore](params)
method is never invoked. The same code path completes normally when all Workers run in a
single process (local dev / one workerd instance).

Environment

  • wrangler ^4.103.0
  • compatibility_date: 2026-02-02
  • compatibility_flags: ["experimental", "allow_irrevocable_stub_storage"]
  • Deployment topology: multiple separate, service-bound Workers, plus Durable Objects hosted
    in one of them. The behavior was observed only in a real multi-Worker deployment (separate
    processes/isolates), not in a single-process runtime.

Setup under which it was observed

Three execution contexts are involved:

  1. Caller Worker — a separate Worker holding a service-binding Fetcher to a
    WorkerEntrypoint in another Worker.
  2. Host Worker — hosts both that WorkerEntrypoint and the Durable Object namespace.
  3. The DO — a Durable Object in the Host Worker that owns the persistent stub. The stub is
    created inside the DO via ctx.restore(params), implements [restore](params), and is stored
    in the DO's storage under allow_irrevocable_stub_storage.

The persistent stub is delivered to the Caller Worker by value across two hops, then invoked there:

DO ──(returns persistent stub)──▶ WorkerEntrypoint (Host) ──(returns it)──▶ Caller Worker
Caller Worker:  stub.someMethod(args)      // <-- hangs

Minimal reproduction (sketch)

Two Workers, service-bound. host exposes a DO and a WorkerEntrypoint; caller invokes
through the entrypoint.

// ---- host Worker ----
import { DurableObject, WorkerEntrypoint, RpcTarget, RpcStub, restore } from "cloudflare:workers";

class Greeter extends RpcTarget {
  constructor(private greeting: string) { super(); }
  greet(name: string) { return `${this.greeting}, ${name}!`; }
}

export class HostDO extends DurableObject {
  [restore](params: { greeting: string }) {
    return new Greeter(params.greeting);   // <-- rebuilds the live target
  }

  async makePersistentStub(): Promise<RpcStub<Greeter>> {
    const stub = await this.ctx.restore({ greeting: "Hello" });  // persistent stub
    // (in the real system this is stored under allow_irrevocable_stub_storage and read back later)
    return stub;   // returned by value to the caller
  }
}

export class HostEntry extends WorkerEntrypoint {
  async getStub(): Promise<RpcStub<Greeter>> {
    const ns = this.ctx.exports.HostDO;
    const doStub = ns.get(ns.idFromString(/* fixed id */));
    return await doStub.makePersistentStub();   // re-export DO's persistent stub out of host
  }
}

// ---- caller Worker (separate Worker, service-bound to host) ----
export default {
  async fetch(req, env) {
    const stub = await env.HOST.getStub();   // persistent stub, two hops out
    const result = await stub.greet("world"); // <-- expected "Hello, world!"; observed: hangs at 0 CPU
    return new Response(result);
  }
};

Expected: "Hello, world!". Observed in a real two-Worker deployment: stub.greet("world")
hangs at 0 CPU until canceled; HostDO[restore] is never called.

Expected behavior

Invoking a method on the stub from the Caller Worker routes back to the Host Worker / DO, drives
the stub's [restore](params) to rebuild the live target, invokes the method, and returns the
result — as it does when everything runs in one process.

Observed behavior

  • The Caller Worker's method invocation hangs. Measured wall-clock durations of ~100–190s at
    0 CPU before the runtime cancels the request (in our case via a Durable Object alarm's
    wall-clock limit; the alarm is then killed and its buffered logs discarded).
  • The DO's [restore] and the target method never run: no logs from inside [restore] or the
    target ever appear, and the DO invocation is ultimately canceled.
  • The Caller Worker receives a sanitized "internal error; reference = <id>" after cancellation.

Additional observations

  • Single-process works. With all Workers running in one process (local dev), the same code —
    the stub is still passed to the other Worker and invoked there — completes normally.
  • Not a pipelining / un-awaited-promise artifact. We changed the intermediate
    WorkerEntrypoint hop to await the DO RPC, so the Caller Worker received a resolved, concrete
    stub rather than a pipelined promise. The invocation still hung.
  • Ordinary cross-Worker RPC is unaffected. Calls on the service-binding Fetcher, and calls
    on ordinary (non-persistent, live) RpcTarget objects returned across the same boundary,
    complete normally in the same deployment. Only invoking a [restore]()-backed stub that was
    re-exported to another Worker exhibits the hang.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions