denoland/celldPublic

self-hosted, distributed Durable Objects

Missing Symbol.dispose on plain RPC object and array results

TN

Hey, thanks for creating celld!

`using result = await service.method()` throws in celld 0.6.2 when the method returns a plain object or array. The RPC call succeeds and returns the expected data, but the result lacks a `Symbol.dispose`. The same code works in workerd.

Had my agent put together a small reproduction and compare it against workerd.

Would appreciate it if you could take a look. Thanks!

**AI below** ----

## Environment

Tested locally, Linux x86-64:

- celld 0.6.2, official `celld-x86_64-unknown-linux-gnu` release binary; SHA-256 `0c9ef440f566156176c3d538e4a3c4034f43775a5851f3ff862938533b5aceac`. - esbuild 0.28.2, available on `PATH` for `celld dev`. - Reference: Wrangler 4.148.0 with workerd 1.20261006.1, using `wrangler dev --local`. - Worker compatibility date: `2026-10-08`; no compatibility flags.

## Minimal reproduction

Save these two files in an empty directory.

`wrangler.jsonc`:

```json { "name": "rpc-disposal-repro", "main": "worker.js", "compatibility_date": "2026-10-08" } ```

`worker.js`:

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

export class ExampleService extends WorkerEntrypoint { record() { return { value: "example" }; } array() { return ["example"]; } primitive() { return 1; } }

export default { async fetch(request, _env, ctx) { const url = new URL(request.url); const kind = url.searchParams.get("kind") ?? "record"; if (!["record", "array", "primitive"].includes(kind)) return new Response("Invalid kind", { status: 400 });

const result = await ctx.exports.ExampleService[kind](); const hasDispose = typeof result[Symbol.dispose] === "function";

if (url.pathname === "/inspect") return Response.json({ result, hasDispose });

try { using disposable = result; return Response.json({ result: disposable, hasDispose, using: "ok" }); } catch (error) { return Response.json({ hasDispose, error: String(error) }, { status: 500 }); } }, }; ``` Start celld from that directory:

```sh celld dev . --port 9876 --no-watch ```

In another terminal:

```sh curl -i 'http://127.0.0.1:9876/inspect?kind=record' curl -i 'http://127.0.0.1:9876/using?kind=record' curl -i 'http://127.0.0.1:9876/inspect?kind=array' curl -i 'http://127.0.0.1:9876/using?kind=array' curl -i 'http://127.0.0.1:9876/inspect?kind=primitive' ```

For comparison, start the same files with the installed Wrangler:

```sh wrangler dev --local --port 9877 --show-interactive-dev-session=false ```

Repeat the five requests with port `9877`.

## Results

| Request | celld 0.6.2 | workerd 1.20261006.1 | | ------------------------- | -------------------------------------------- | ---------------------------- | | `/inspect?kind=record` | HTTP 200, correct data, `hasDispose: false` | HTTP 200, `hasDispose: true` | | `/using?kind=record` | HTTP 500, `TypeError: Object not disposable` | HTTP 200, `using: "ok"` | | `/inspect?kind=array` | HTTP 200, correct data, `hasDispose: false` | HTTP 200, `hasDispose: true` | | `/using?kind=array` | HTTP 500, `TypeError: Object not disposable` | HTTP 200, `using: "ok"` | | `/inspect?kind=primitive` | HTTP 200, `result: 1`, `hasDispose: false` | Same |

The celld record response before disposal is:

```json { "result": { "value": "example" }, "hasDispose": false } ```

The failing record and array responses are:

```json { "hasDispose": false, "error": "TypeError: Object not disposable" } ```

According to the [Workers RPC lifecycle docs](https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/#disposing-rpc-objects-disposes-stubs-that-are-part-of-that-object), both objects and arrays should support `using`, even when they contain only data. Primitive results are exempt. Disposing an aggregate result should also release any RPC stubs received within it.

## Possible cause

The agent also looked at [`__rpcDes` in celld 0.6.2](https://github.com/denoland/celld/blob/v0.6.2/crates/celld/js/harness.js#L5050) and suspects this is where the disposer is missing. It returns ordinary structured-clone replies directly when the first byte is `0xff`. In the tagged-reply branch, it adds a disposer only if revived handles or remote disposers are present.

It looks like both reply paths would need to add a disposer for plain objects and arrays while keeping the existing cleanup for RPC stubs. The tests above cover records and arrays, with a primitive as a control; the lifecycle behavior of other RPC value types hasn't been checked.

## Impact and workaround

Using `using` with an ordinary RPC object or array result throws after the method has returned successfully.

For methods known to return only ordinary data, callers can temporarily replace unconditional `using` with reading or copying the result and conditionally calling `result[Symbol.dispose]?.()` in `finally`. This does not repair celld's RPC contract or establish cleanup support for arbitrary remote object graphs.