Skip to content

Fix RPC resource leaks in workflows - #1668

Merged
threepointone merged 1 commit into
mainfrom
fix/rpc-resource-leaks
Jun 4, 2026
Merged

threepointone merged 1 commit into
mainfrom
fix/rpc-resource-leaks

Conversation

@ghostwriternr

@ghostwriternr ghostwriternr commented Jun 3, 2026 •

Copy link
Copy Markdown
Member

AgentWorkflow and ThinkWorkflow were not disposing the Durable Object references returned by waitForEvent(), submitMessages(), and getAgentByName(). These references hold an open connection to the remote object and must be explicitly disposed; without it, the runtime logs "RPC stub was not disposed" warnings and the connections leak for the duration of the workflow run. Because workflows can be long-lived and may retry, the leaks compound over time.

Regression tests are included for the approval flow, the Think prompt flow, and the agent stub cleanup.


Open in Devin Review

The Workers runtime warns about un-disposed Cap'n Proto resources when
RPC methods return references. Failing to dispose these references
causes memory leaks and noisy runtime warnings.

This ensures all RPC results and Durable Object stubs created by
AgentWorkflow and ThinkWorkflow are properly disposed when they are
no longer needed. This includes wait events in the approval flow,
submission and wait events during prompts, and the Agent Durable
Object stub itself.

Regression tests are included to verify disposal and ensure payloads
can still be parsed correctly after disposal.
@changeset-bot

changeset-bot Bot commented Jun 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 511478b

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

This PR includes changesets to release 2 packages
Name Type
agents Patch
@cloudflare/think 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 5 additional findings.

Open in Devin Review

@pkg-pr-new

pkg-pr-new Bot commented Jun 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

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

@cloudflare/ai-chat

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

@cloudflare/codemode

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

hono-agents

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

@cloudflare/shell

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

@cloudflare/think

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

@cloudflare/voice

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

@cloudflare/worker-bundler

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

commit: 511478b

@threepointone

Copy link
Copy Markdown
Contributor

/bonk do a review on this, look for edge cases and things we might not have thought of, is the ux/dx alright, do we have enough test coverage

@ask-bonk ask-bonk 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.

Review: RPC Resource Leak Fixes in Workflows

I went through the full diff, traced every dispose path, and analyzed the test coverage. Overall this is a solid, well-scoped fix. Here are my findings:

What's good

  1. Correct execution ordering for _disposeAgent() — The finally block in the wrapped run() only fires after _runWithErrorReporting completes (including the _autoReportError call inside its catch). So the agent stub is alive for error reporting and disposed after. This is the trickiest part of the change and it's handled correctly.

  2. waitForApproval try/finally — The event from step.waitForEvent() is properly disposed on both the approval and rejection paths. The finally ensures cleanup even when WorkflowRejectedError is thrown.

  3. ThinkWorkflow submitMessages disposal — Stripping the RPC stub down to { submissionId } inside the step.do() callback before disposing is the right pattern. The serializable plain object survives workflow replay; the RPC stub doesn't leak.

  4. ThinkWorkflow waitForEvent result disposal — Same try/finally pattern as the agents package. The event stays alive during validation/parsing and gets disposed after.

  5. _disposeAgent is idempotent — Setting _agent = undefined before calling disposeIfPresent means double-calls are safe. The test confirms this.

Issues and edge cases

1. broadcastToClients() after _disposeAgent() — user-code footgun

User code that stores a reference to this (the workflow instance) and calls this.broadcastToClients() from an async callback that resolves after run() returns will hit the "Agent not initialized" error. This was always possible in theory, but now that _disposeAgent() actively clears the stub, it's more likely to surface.

This is arguably correct behavior (you shouldn't use the agent after run() completes), but it may surprise users. A brief note in the broadcastToClients JSDoc that it's only valid during run() would help. Not a blocker.

2. Duplicated disposeIfPresent / isDisposableResource utilities

The exact same DisposableResource type, isDisposableResource type guard, and disposeIfPresent function are copy-pasted in both packages/agents/src/workflows.ts and packages/think/src/workflows.ts. Since ThinkWorkflow already imports from agents/workflows, it would be cleaner to export disposeIfPresent from agents/workflows and reuse it.

The existing DisposableStore in packages/agents/src/core/events.ts uses a different convention (.dispose() method vs Symbol.dispose), so it can't be reused directly, but the utility functions could be shared.

3. No test for _disposeAgent() being called when _initAgent itself throws

If _initAgent throws (e.g., missing binding, bad agent name), the try/finally in the wrapped run() will call _disposeAgent() with _agent still undefined (since the assignment at the end of _initAgent never ran). This is safe because disposeIfPresent(undefined) is a no-op, but there's no test covering this path.

4. No test for the super.run() path with disposal

The wrapped run() has a second code path (lines 161-167) for when __agentInitCalled is already true (i.e., called via super.run()). This path does NOT run _disposeAgent() — only the outer caller does. This is correct, but there's no test validating that the agent stub survives through a super.run() call and is only disposed once at the top level.

5. ThinkWorkflow cancelSubmission in timeout path — agent stub may be disposed before cancellation completes

In _waitForPromptEvent (think/workflows.ts:204-221), when waitForEvent times out, the catch block calls this.agent.cancelSubmission(...). This RPC call goes through the agent stub. If the timeout error propagates up and the outer finally in _promptStep tries to dispose the event (which doesn't exist since waitForEvent threw), and then the error continues up to the AgentWorkflow.run() wrapper which calls _disposeAgent() — the question is whether cancelSubmission has completed by then.

Looking at the flow: _waitForPromptEvent throws ThinkPromptTimeoutError → caught by _promptStep? No, it's not caught there, it propagates up → caught by _runWithErrorReporting → _autoReportError runs → then finally { _disposeAgent() }. Since cancelSubmission is awaited inside _waitForPromptEvent, it completes before the throw. So this is actually fine. But it's worth noting that cancelSubmission uses the agent stub that will be disposed shortly after — if cancellation itself triggers a long-running operation, the stub gets disposed while that operation is in-flight. This is probably fine in practice since cancelSubmission is awaited.

Test coverage assessment

The tests are well-structured unit tests that validate the disposal mechanics using Object.create on the prototype (avoiding the need for a real ExecutionContext). Coverage includes:

  • Agent stub disposal + idempotency
  • waitForApproval approval path + rejection path disposal
  • ThinkWorkflow submission result disposal
  • ThinkWorkflow wait event disposal
  • ThinkWorkflow output read-before-dispose ordering
  • ThinkWorkflow timeout path with cancellation

Missing coverage:

  • _initAgent failure path (not a big deal, it's a no-op dispose)
  • super.run() inheritance path (the prototype tests check inheritance but not the disposal-during-super.run scenario)
  • Error in _disposeAgent itself (what if Symbol.dispose throws?) — currently swallowed silently which is fine, but untested
  • Integration test that actually verifies "RPC stub was not disposed" warnings are gone (though this may be impractical to test in vitest)

Summary

The fix is correct and well-targeted. The execution ordering of dispose-vs-error-reporting is the most critical part and it's handled properly. The duplication of helper functions is minor but worth cleaning up. Test coverage is good for the new paths but a couple of edge cases (init failure, super.run disposal) are untested. None of the gaps are blockers.

@@ -34,7 +34,11 @@
*/

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.

Nit: This utility triad (DisposableResource, isDisposableResource, disposeIfPresent) is duplicated verbatim in packages/think/src/workflows.ts. Since ThinkWorkflow already imports from agents/workflows, consider exporting disposeIfPresent from the agents package and reusing it here. Would reduce the maintenance surface and keep the dispose semantics in one place.

this._agent = undefined;
this.__agentInitCalled = false;
disposeIfPresent(agent);
}

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.

Minor: If Symbol.dispose on the agent stub throws, the error propagates out of the finally block and masks the original error from run(). Unlikely in practice (CF stubs dispose cleanly), but wrapping in try/catch for consistency with the best-effort pattern used elsewhere would make it bulletproof:

private _disposeAgent(): void {
  const agent = this._agent;
  this._agent = undefined;
  this.__agentInitCalled = false;
  try {
    disposeIfPresent(agent);
  } catch {
    // best-effort cleanup
  }
}

}

function disposeIfPresent(value: unknown): void {
if (isDisposableResource(value)) {

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.

These three helper functions (DisposableResource, isDisposableResource, disposeIfPresent) are identical to the ones in packages/agents/src/workflows.ts. Since ThinkWorkflow already imports from agents/workflows (line 10), consider exporting disposeIfPresent from there and importing it here to avoid the duplication.

@ask-bonk

ask-bonk Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #1668. Here's a summary of the findings:

Overall verdict: Solid fix, no blockers.

The PR correctly fixes RPC resource leaks in AgentWorkflow and ThinkWorkflow by:

  • Disposing the agent stub via _disposeAgent() in a finally block after run() completes
  • Disposing waitForEvent results in waitForApproval via try/finally
  • Stripping submitMessages RPC results down to plain serializable data before disposing in ThinkWorkflow
  • Disposing waitForEvent results in ThinkWorkflow's prompt step

Key edge cases I flagged:

  1. _disposeAgent should wrap disposeIfPresent in try/catch — if Symbol.dispose throws, it would propagate out of the finally block and mask the original error from run()
  2. Duplicated utility functions — disposeIfPresent/isDisposableResource are copy-pasted across both packages; should be exported from agents/workflows and imported by @cloudflare/think
  3. broadcastToClients() post-dispose behavior change — async callbacks that resolve after run() will now throw instead of silently leaking; JSDoc note recommended
  4. Missing test paths — _initAgent failure, super.run() disposal ordering, and Symbol.dispose throwing are untested (none are blockers)

github run

@threepointone threepointone 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.

thanks for this!

@threepointone
threepointone merged commit d40cc8a into main Jun 4, 2026
8 checks passed
@threepointone
threepointone deleted the fix/rpc-resource-leaks branch June 4, 2026 07:53
@github-actions github-actions Bot mentioned this pull request Jun 4, 2026
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.

2 participants