Skip to content

Reclaim idle MCP session runtimes - #1800

Merged
RhysSullivan merged 1 commit into
mainfrom
fix/session-runtime-idle-disposal
Aug 28, 2026
Merged

Reclaim idle MCP session runtimes#1800
RhysSullivan merged 1 commit into
mainfrom
fix/session-runtime-idle-disposal

Conversation

@RhysSullivan

Copy link
Copy Markdown
Collaborator

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 waitUntil that 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-free Reflect form 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:

  • Open client streams do not pin the runtime. The session Durable Object holds no connection for a standalone GET stream — 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 reads activeStreamCount is left exactly as it was.
  • Disposal does end the client's stream, but through the MCP server's own 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 behaviour mcp-sse-replay already 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/cloudflare unit tests 91 passed. typecheck, lint, and format:check clean.

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.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

Torn down — the PR is closed.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
executor-cloud a66f358 Aug 28 2026, 03:58 AM

@pkg-pr-new

pkg-pr-new Bot commented Aug 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

@executor-js/cli

npm i https://pkg.pr.new/@executor-js/cli@1800

@executor-js/config

npm i https://pkg.pr.new/@executor-js/config@1800

@executor-js/execution

npm i https://pkg.pr.new/@executor-js/execution@1800

@executor-js/sdk

npm i https://pkg.pr.new/@executor-js/sdk@1800

@executor-js/codemode-core

npm i https://pkg.pr.new/@executor-js/codemode-core@1800

@executor-js/runtime-quickjs

npm i https://pkg.pr.new/@executor-js/runtime-quickjs@1800

@executor-js/plugin-file-secrets

npm i https://pkg.pr.new/@executor-js/plugin-file-secrets@1800

@executor-js/plugin-graphql

npm i https://pkg.pr.new/@executor-js/plugin-graphql@1800

@executor-js/plugin-keychain

npm i https://pkg.pr.new/@executor-js/plugin-keychain@1800

@executor-js/plugin-mcp

npm i https://pkg.pr.new/@executor-js/plugin-mcp@1800

@executor-js/plugin-onepassword

npm i https://pkg.pr.new/@executor-js/plugin-onepassword@1800

@executor-js/plugin-openapi

npm i https://pkg.pr.new/@executor-js/plugin-openapi@1800

executor

npm i https://pkg.pr.new/executor@1800

commit: a66f358

@RhysSullivan
RhysSullivan marked this pull request as ready for review August 28, 2026 04:12
@RhysSullivan
RhysSullivan merged commit eac13e7 into main Aug 28, 2026
44 checks passed
@RhysSullivan RhysSullivan mentioned this pull request Aug 28, 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.

1 participant