Skip to content

Cap resident MCP session runtimes with LRU eviction - #1842

Merged
RhysSullivan merged 5 commits into
mainfrom
residency-cap
Aug 29, 2026
Merged

Cap resident MCP session runtimes with LRU eviction#1842
RhysSullivan merged 5 commits into
mainfrom
residency-cap

Conversation

@RhysSullivan

@RhysSullivan RhysSullivan commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

What

Isolates were letting resident MCP session runtimes accumulate unbounded.
Idle disposal exists, but it is alarm-driven per session, so a busy isolate
with many quiet-but-connected sessions could still pile up far past what the
isolate's memory can hold, until the platform reset every session on it.

This turns the existing residency gauge into an enforcing soft cap:

  • session-runtime-residency.ts gets a small dependency-free registry
    (registerResidentSession, touchResidentSession, releaseResidentSession,
    pickEvictionCandidate) plus RESIDENT_RUNTIME_SOFT_CAP (32).
  • Before building a new runtime, init requests at most one eviction of the
    least-recently-active evictable session if the isolate is already at the
    cap. Eligibility mirrors the same signals the idle-alarm path already uses
    (no active stream, no paused/running executions), split into a cheap sync
    prefilter used for LRU selection and an authoritative async re-check right
    before the actual dispose, so a session that started work in between is
    left alone.
  • Eviction reuses the exact same disposal path as idle disposal
    (disposeIdleRuntime), now parameterized with a reason: "idle" | "cap"
    attribute on the existing mcp.session.idle_runtime_dispose span (span
    name kept for dashboard continuity).
  • If nothing is currently evictable, init proceeds anyway — a memory-pressure
    mechanism must never itself be the reason a session fails to start — and
    mcp.isolate.cap_overflow: true is recorded on the init span instead.

Eviction runs in the candidate's own context, not the evictor's

A session's postgres.js socket, storage handle, and span exporter are bound
to the Durable Object request/IoContext that created them. The evicting
session's init cannot tear another session's runtime down directly — that
would run I/O created under one request's context from inside a different
one, which workerd rejects (or silently no-ops) in production, even though
nothing catches it in an in-process unit test where both sessions are just
plain JS objects in the same isolate.

So dispose on a registry entry no longer runs the candidate's teardown —
it sends the candidate a REQUEST that the candidate's own runtime executes in
its own context:

  • The base class gets a protected requestSelfEviction() hook and a
    supportsCapEviction() gate. A host that can route a self-addressed
    request implements both; a host that can't simply never registers a
    dispose, so its sessions are never eviction candidates — degrading to
    observational rather than breaking.
  • apps/cloud and apps/host-cloudflare implement requestSelfEviction by
    calling their own Durable Object stub (mcpSessionStub(...).requestCapEviction()),
    the same pattern forwardModelResumeToOwner already uses to route a resume
    across sessions.
  • requestCapEviction is a new public RPC method on the DO that runs
    evictResidentRuntimeForCap() — the same authoritative re-check-then-dispose
    logic as before, just invoked from the candidate's own request instead of
    the evictor's.
  • The evictor fires this request fire-and-forget via ctx.waitUntil from
    init and moves on; init never waits on another session's teardown to
    complete its own request.
  • The registry entry is stamped with evictionRequestedAt the moment the
    request is sent, and pickEvictionCandidate skips any entry with a request
    still inside a short grace window — so a candidate whose request is slow or
    stuck can't be re-targeted by every subsequent init before it resolves.

Duplicate and failed requests are both safe:

  • Two different sessions' inits can pick the same candidate before either
    request lands. The candidate's own handler re-checks liveness every time,
    and its teardown is idempotent, so the second request either finds the
    runtime already gone (no-op) or finds it busy again and leaves it alone.
  • If the request itself fails to land (the stub call rejects), the registry
    entry is left in place — only the candidate's own successful teardown
    removes it — and the failure is logged. The evictor's own init is never
    blocked or failed by another session's eviction outcome.

Closing two admission/teardown races

Two further races surfaced on review:

  • Concurrent cold inits could bypass the cap. The cap check compared the
    isolate's resident count against the cap, but residency only counts a
    session once its (async) build finishes. Several overlapping cold inits
    admitted at the same moment each read the count as still under the cap and
    none of them evicted anything, so residency could overshoot the cap by
    however many builds landed together — the exact burst the cap exists to
    bound. session-runtime-residency.ts now also tracks an in-flight
    cold-build count, reserved the moment an init is admitted and released once
    that build finishes or fails; the cap check is now
    resident count + in-flight count >= cap, so a second concurrent admission
    sees the first one's reservation and evicts instead of also passing the
    check for free.
  • A request landing mid-teardown could run against a half-closed runtime.
    closeRuntime (used by both idle disposal and cap eviction) left
    initialized true across its async closes (server.close(),
    dbHandle.end()), so a request arriving in that window took init's
    early-return path and ran against a server/engine that were already gone or
    partway through closing. closeRuntime now flips initialized to false
    before its first async close and tracks the in-progress disposal; init
    awaits any in-progress disposal before deciding whether to rebuild, and a
    second disposal trigger landing while one is already running (e.g. the idle
    alarm and a cap eviction request together) waits on the same disposal
    instead of tearing the runtime down twice.

Also, the cap-eviction e2e scenario now closes every MCP session it opens via
an Effect.ensuring finalizer, instead of leaving ~34 real sessions open for
the rest of the e2e run.

Closing three interruption-path edges

The two races above were closed for the ordinary failure paths, but an
interrupt landing at the same seams could still slip through:

  • Cold-build reservation leak in the interrupt gap. The in-flight
    reservation was taken by evictForCapIfNeeded, but only released by an
    Effect.ensuring scoped to the build block that starts right after that
    admission — leaving a gap between the two. An interrupt landing in exactly
    that gap leaked the reservation permanently, since nothing ever released
    it. The release is now a single Effect.ensuring around init's entire
    program, so there is no window between acquiring the reservation and being
    covered by its release.
  • Disposal could signal completion before teardown actually finished.
    closeRuntime's Effect.ensuring resolves disposingRuntime
    unconditionally, and a waiting init treats that resolution as "the
    resources are actually released." An interrupt landing mid-teardown (e.g.
    while still inside the gated server.close()) used to let that ensuring
    fire anyway, so a waiting init could rebuild against a still-open server
    or un-cleared engine. The teardown body is now Effect.uninterruptible,
    so an interrupt request has to wait for it to actually finish first.
  • The e2e cleanup could miss a partially-established session. The
    session id was only pushed into the cleanup list after the full
    initialize + notifications/initialized handshake returned. A failed
    notification, or an interrupt landing between the two requests, orphaned a
    real session with no cleanup entry. The id is now recorded the moment it's
    known — right after initialize responds — not after the handshake
    completes.

Verification

Unit tests (agent-session-durable-object.test.ts): 38/38 passing, including
new cases for concurrent cold-build admission, mid-disposal request handling,
an init interrupted between cap admission and build completing (reservation
still released, not leaked), and a disposal interrupted mid-teardown (a
waiting init stays blocked until the now-uninterruptible teardown actually
finishes, instead of rebuilding early). Typecheck, lint, and format all
clean.

e2e:

  • cloud/mcp-session-idle-runtime-disposal.test.ts — 1/1 passing, confirming
    the shared disposal path is unaffected.
  • cloud/mcp-session-cap-eviction.test.ts — 1/1 passing: a genuinely real,
    workerd-backed eviction test against a lowered
    MCP_RESIDENT_RUNTIME_SOFT_CAP (see e2e/setup/resident-runtime-cap.ts),
    asserting a mcp.session.idle_runtime_dispose span with
    dispose_reason: "cap" was exported for one of the opened sessions, that
    the evicted session still serves its next call correctly after restoring,
    and cleaning up every session it opened — including any left partially
    established.

Isolates were letting resident session runtimes pile up unbounded past
the point idle disposal alone could keep up, so memory pressure kept
climbing until the platform reset the whole isolate. init now evicts
the least-recently-active evictable session once the isolate hits a
soft cap, reusing the same disposal path and eligibility signals the
idle alarm already used. A cap that finds nothing evictable never
blocks or fails init; it only marks the init span.
@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

Torn down — the PR is closed.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 29, 2026

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 aa2dc3b Commit Preview URL

Branch Preview URL
Aug 29 2026, 09:05 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 29, 2026

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 aa2dc3b Aug 29 2026, 09:05 AM

@pkg-pr-new

pkg-pr-new Bot commented Aug 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@executor-js/cli

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

@executor-js/config

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

@executor-js/execution

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

@executor-js/sdk

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

@executor-js/codemode-core

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

@executor-js/runtime-quickjs

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

@executor-js/plugin-file-secrets

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

@executor-js/plugin-graphql

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

@executor-js/plugin-keychain

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

@executor-js/plugin-mcp

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

@executor-js/plugin-onepassword

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

@executor-js/plugin-openapi

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

executor

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

commit: aa2dc3b

…ontext

Cap eviction now asks the candidate's own Durable Object stub to tear itself
down instead of running its teardown inside the evicting session's request
context, where its I/O objects aren't valid. Duplicate or failed requests are
safe no-ops; a recently-requested candidate is skipped by the next pick.
Reserve an in-flight cold-build slot at admission so concurrent cold
inits at the cap evict instead of all passing the check before any of
their builds finish incrementing residency.

Track in-progress runtime disposal and flip initialized before the
first async close, so a request landing mid-teardown awaits the same
disposal and rebuilds instead of running against a half-closed
runtime; a second concurrent disposal trigger waits on the same
teardown instead of running it twice.

Close every MCP session opened by the cap-eviction e2e scenario via
Effect.ensuring.
… cleanup

- release the cold-build reservation with one Effect.ensuring around all of
  init, so an interrupt between cap admission and the build can't leak it
- make closeRuntime's teardown uninterruptible so disposingRuntime never
  resolves against half-released resources
- record e2e session ids for cleanup as soon as they're minted, not after
  the handshake completes, so a failed notification can't orphan a session
@RhysSullivan
RhysSullivan marked this pull request as ready for review August 29, 2026 18:39
@RhysSullivan
RhysSullivan merged commit a679c3c into main Aug 29, 2026
44 checks passed
@RhysSullivan
RhysSullivan deleted the residency-cap branch August 29, 2026 18:39
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