Reclaim idle MCP session runtimes - #1800
Merged
Merged
Conversation
The session Durable Object's idle timeout never ran. The session arms an idle alarm on every request, but the agents framework recomputes the Durable Object alarm from its own schedule table and keep-alive refcount and deletes it when it finds neither -- which it does at the end of every ordinary tool call, from a waitUntil just after the response goes out. A session that had just served a request was therefore left with no alarm, and its execution runtime stayed resident until the platform evicted the whole object. Durable Objects share one isolate heap, so runtimes that are never reclaimed accumulate there and exhaust it, and the allocation that fails is whichever comes next anywhere in the isolate. Re-assert the session's idle deadline after the framework has arranged its own alarm, and only while a runtime is actually resident. Record a per-isolate resident-runtime gauge on runtime build and on disposal so the reclaim is confirmable rather than inferred.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-marketing | a66f358 | Commit Preview URL Branch Preview URL |
Aug 28 2026, 03:55 AM |
Contributor
Cloudflare previewTorn down — the PR is closed. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
executor-cloud | a66f358 | Aug 28 2026, 03:58 AM |
@executor-js/cli
@executor-js/config
@executor-js/execution
@executor-js/sdk
@executor-js/codemode-core
@executor-js/runtime-quickjs
@executor-js/plugin-file-secrets
@executor-js/plugin-graphql
@executor-js/plugin-keychain
@executor-js/plugin-mcp
@executor-js/plugin-onepassword
@executor-js/plugin-openapi
executor
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The MCP session Durable Object has an idle timeout that disposes a session's execution runtime — the execution engine and its executor closure, the built tool catalog, and a live database handle — once the session goes quiet. That timeout never ran.
The session arms an idle alarm on every request. The agents framework independently recomputes the Durable Object alarm from its own schedule table and keep-alive refcount, and when it finds neither it does not leave the alarm alone — it deletes it. It releases the last keep-alive reference at the end of every ordinary tool call, from a
waitUntilthat runs just after the response goes out. So the idle alarm the session had armed moments earlier was erased, and a session that had just served a request was left with no alarm at all. Its runtime then stayed resident until the platform evicted the whole object.Durable Objects are colocated many-to-one onto an isolate with a single heap, so runtimes that are never reclaimed accumulate there. When that heap is exhausted the allocation that fails is whichever one comes next, anywhere in the isolate — which is why the resulting failures tended to name storage rather than the runtimes that had actually consumed the memory.
Worth stating plainly: an out-of-memory isolate cannot be provoked black-box — filling a shared heap on demand is not something a client can ask for. Reclamation, not the allocation failure, is the testable contract here.
Fix
The idle deadline belongs to the session, not to the framework's scheduler, so it is re-asserted after the framework has arranged whatever it needs. It is re-armed only while a runtime is actually resident: once there is nothing left to reclaim the framework's answer is right, and re-arming unconditionally would spin the alarm forever.
The framework declares that scheduling method
private, which TypeScript will not let a subclass redeclare at all, so the wrapper is installed on the prototype. At runtime that is an ordinary override; the cast-freeReflectform is only about satisfying the compiler.Disposal now also emits a span carrying a per-isolate resident-runtime gauge, with the same gauge recorded on runtime build. That is what makes the mechanism confirmable in production — residency should now fall back toward zero as sessions go idle instead of climbing with the number of connected clients. Isolate heap figures are feature-detected and simply absent on workerd, which does not expose them today.
Two things the investigation ruled out, both of which had looked like the cause:
ctx.getWebSockets()is empty — so an open stream is not visible to the idle policy and never extended its lease. The alarm-policy branch that readsactiveStreamCountis left exactly as it was.close(), not the connection sweep. The transport is part of the object being disposed, so "dispose but keep the stream" is not a policy toggle. Clients reconnect and replay, which is the behaviourmcp-sse-replayalready pins.Testing
New scenario
e2e/cloud/mcp-session-idle-runtime-disposal.test.ts: opens a session, holds a client stream open across the idle window, executes once, goes idle, then asserts on exported spans that the runtime was released on the idle clock with the residency gauge recorded, and that the next call on the same session still returns the right answer after restoring underneath the client.Red/green on a single line — commenting out the re-arm makes it fail with "no idle-runtime-disposal span exported for session …" after polling the full 20s, because before this change that span never appeared for any session. With the re-arm, disposal lands at
idleMs≈ the configured idle timeout.Neighbouring session suites run green together with it (13 tests):
mcp-session-cold-init,mcp-client-sessions,mcp-sse-replay,mcp-priming-reconnect.@executor-js/cloudflareunit tests 91 passed.typecheck,lint, andformat:checkclean.