// Cloud: an idle MCP session gives its execution runtime back to the isolate // even while the client is still holding a stream open. // // The defect this pins: the idle timeout never actually ran. The session armed // 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. So a session // that had just served a request was left with no alarm at all, its idle // deadline silently dropped, and its execution runtime resident until the // platform evicted the whole object. Durable Objects share one isolate heap, so // enough never-reclaimed runtimes exhaust it and the next allocation anywhere // in that isolate fails. // // The contract asserted here, in three parts: // // 1. an idle session's runtime IS released, and the exported span says so. // Before the fix this span never appeared at all, for any session; // 2. the release reports the isolate's resident-runtime gauge, which is what // makes the mechanism confirmable in production rather than inferred; // 3. the very next call on the SAME session works and returns the right // answer, restoring the runtime underneath the client transparently. // // A client stream is deliberately held open across the idle window, because // that is the shape real clients have. Note what it does NOT do: it does not // keep the runtime alive. The session Durable Object holds no connection for a // standalone GET stream (`ctx.getWebSockets()` is empty), so the stream is not // visible to the idle policy at all, and reclaiming the runtime ends it — the // client reconnects and replays, which is the behaviour `mcp-sse-replay` pins. // // The out-of-memory failure itself is not reachable from a black-box test — // filling a shared isolate heap on demand is not something a client can ask // for — so reclamation, not the allocation failure, is the testable contract. import { randomUUID } from "node:crypto"; import { expect } from "@effect/vitest"; import { Effect, Schedule } from "effect"; import { scenario } from "../src/scenario"; import { Mcp, Target, Telemetry } from "../src/services"; import type { Identity } from "../src/target"; import { configuredMcpSessionTimeoutMs } from "../setup/mcp-session-timeouts"; const PROTOCOL_VERSION = "2025-03-26"; const JSON_AND_SSE = "application/json, text/event-stream"; /** Past the idle window with room for the alarm to actually fire. */ const IDLE_GAP_MS = configuredMcpSessionTimeoutMs() + 4_000; const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; const mcpHeaders = (bearer: string, sessionId?: string) => ({ accept: JSON_AND_SSE, authorization: `Bearer ${bearer}`, "content-type": "application/json", "mcp-protocol-version": PROTOCOL_VERSION, ...(sessionId ? { "mcp-session-id": sessionId } : {}), }); const postJson = (mcpUrl: string, bearer: string, body: unknown, sessionId?: string) => fetch(mcpUrl, { method: "POST", headers: mcpHeaders(bearer, sessionId), body: JSON.stringify(body), }); const openSession = async (mcpUrl: string, bearer: string): Promise => { const initialized = await postJson(mcpUrl, bearer, { jsonrpc: "2.0" as const, id: "initialize", method: "initialize", params: { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "executor-e2e-idle-runtime-disposal", version: "0.0.1" }, }, }); const sessionId = initialized.headers.get("mcp-session-id"); await initialized.text(); expect(initialized.status, "initialize opens a session").toBe(200); if (!sessionId) { // oxlint-disable-next-line executor/no-error-constructor -- boundary: e2e setup precondition. throw new Error("openSession: no mcp-session-id header"); } const notification = await postJson( mcpUrl, bearer, { jsonrpc: "2.0" as const, method: "notifications/initialized" }, sessionId, ); await notification.text(); expect(notification.status, "the client completes the handshake").toBe(202); return sessionId; }; /** * The standalone GET stream a real client leaves open for the whole * conversation. Held open (never aborted) across the idle window — that is the * exact condition the old policy read as "busy". */ type OpenStream = { readonly closed: () => boolean; readonly abort: () => void; }; const openGetStream = async ( mcpUrl: string, bearer: string, sessionId: string, ): Promise => { const abortController = new AbortController(); const response = await fetch(mcpUrl, { method: "GET", headers: { accept: "text/event-stream", authorization: `Bearer ${bearer}`, "mcp-protocol-version": PROTOCOL_VERSION, "mcp-session-id": sessionId, }, signal: abortController.signal, }); expect(response.status, "the standalone GET stream opens").toBe(200); let closed = false; const reader = response.body?.getReader(); // Drain in the background. The stream ending — for any reason — flips // `closed`, which is what part (2) of the contract inspects. void (async () => { if (!reader) { closed = true; return; } // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: reading an aborted stream throws; either way the stream is over. try { for (;;) { const { done } = await reader.read(); if (done) break; } } catch { // fall through } closed = true; })(); return { closed: () => closed, abort: () => abortController.abort("scenario complete"), }; }; const executeBody = (id: string, code: string) => ({ jsonrpc: "2.0" as const, id, method: "tools/call", params: { name: "execute", arguments: { code } }, }); /** Run `execute` and return the response text once the call has fully settled. */ const execute = async ( mcpUrl: string, bearer: string, sessionId: string, id: string, code: string, ): Promise => { const response = await postJson(mcpUrl, bearer, executeBody(id, code), sessionId); const body = await response.text(); expect(response.status, `execute ${id} is served`).toBe(200); return body; }; scenario( "MCP session · an idle session disposes its runtime while the client stream stays open", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; const mcp = yield* Mcp; const telemetry = yield* Telemetry; const identity = yield* target.newIdentity(); const bearer = yield* mcp.mintBearer(emailOf(identity)); const sessionId = yield* Effect.promise(() => openSession(target.mcpUrl, bearer)); // The long-lived client stream, opened before any work and never closed. const stream = yield* Effect.promise(() => openGetStream(target.mcpUrl, bearer, sessionId)); // ---- one call, then silence ------------------------------------------ const marker = `before-${randomUUID().slice(0, 8)}`; const first = yield* Effect.promise(() => execute( target.mcpUrl, bearer, sessionId, "execute-before-idle", `return ${JSON.stringify(marker)};`, ), ); expect(first, "the session executes before going idle").toContain(marker); // ---- go idle, with the stream still connected ------------------------- yield* Effect.sleep(`${IDLE_GAP_MS} millis`); // ---- 1. the runtime was released -------------------------------------- // The alarm carries no client trace context, so this is found by operation // and matched to this session by attribute rather than by trace id. const disposals = yield* telemetry .searchSpans({ operation: "mcp.session.idle_runtime_dispose" }) .pipe( Effect.map((spans) => spans.filter((span) => (span.span.tags["mcp.session.id"] ?? "").includes(sessionId)), ), Effect.filterOrFail( (spans) => spans.length > 0, () => `no idle-runtime-disposal span exported for session ${sessionId}`, ), // The span is exported off the alarm's own flush, which is not on any // response path — same polling grace `expectSpan` uses. Effect.retry(Schedule.both(Schedule.spaced("500 millis"), Schedule.recurs(40))), ); expect(disposals.length, "the idle session released its execution runtime").toBeGreaterThan(0); const disposal = disposals[0]!; expect( Number(disposal.span.tags["mcp.session.idle_ms"]), "the runtime was released because the session went idle, not for some other reason", ).toBeGreaterThanOrEqual(configuredMcpSessionTimeoutMs()); expect( disposal.span.tags["mcp.isolate.resident_runtimes"], "the disposal records the isolate's resident-runtime gauge", ).toBeDefined(); // ---- 3. the next call restores underneath the client ------------------ const after = `after-${randomUUID().slice(0, 8)}`; const second = yield* Effect.promise(() => execute( target.mcpUrl, bearer, sessionId, "execute-after-idle", `return ${JSON.stringify(after)};`, ), ); expect( second, "the same session serves the next call with the right answer after restoring", ).toContain(after); stream.abort(); }), );