|
| 1 | +// Selfhost-only: an MCP session that goes idle past the store's TTL is |
| 2 | +// reclaimed, and the next request on that session id gets the 404 / -32001 cue |
| 3 | +// that tells a client to re-initialize. |
| 4 | +// |
| 5 | +// Why this scenario boots its OWN instance instead of using the shared one: |
| 6 | +// the idle window is a BOOT-TIME operator knob |
| 7 | +// (EXECUTOR_MCP_SESSION_IDLE_TTL_MS), so there is no way to shrink it on a |
| 8 | +// running server. Waiting out the 30-minute default is not an option, and |
| 9 | +// shrinking it on the SHARED instance would silently evict sessions underneath |
| 10 | +// every other selfhost scenario. A dedicated instance on its own port and data |
| 11 | +// dir keeps the short window contained to this file. |
| 12 | +// |
| 13 | +// Why the wait is a single silent sleep rather than a poll loop: every request |
| 14 | +// the store forwards restamps that session's last-seen time. A poll loop is |
| 15 | +// itself the traffic that keeps the session alive, so it could never observe an |
| 16 | +// eviction. The scenario stays completely silent for one window, then makes |
| 17 | +// exactly one request. |
| 18 | +// |
| 19 | +// Auth is the Better Auth session cookie, not an OAuth bearer: self-host's MCP |
| 20 | +// auth provider accepts the cookie/api-key identity path, and the credential is |
| 21 | +// not what is under test here — session lifetime is. |
| 22 | +import { mkdtempSync, rmSync } from "node:fs"; |
| 23 | +import { tmpdir } from "node:os"; |
| 24 | +import { join } from "node:path"; |
| 25 | + |
| 26 | +import { expect } from "@effect/vitest"; |
| 27 | +import { Effect } from "effect"; |
| 28 | + |
| 29 | +import { scenario } from "../src/scenario"; |
| 30 | +import { RunDir, Target } from "../src/services"; |
| 31 | +import { claimAndBoot } from "../src/ports"; |
| 32 | +import { isBootReadinessTimeout } from "../setup/boot"; |
| 33 | +import { bootSelfhost } from "../setup/selfhost.boot"; |
| 34 | +import { SELFHOST_ADMIN, signInSession } from "../targets/selfhost"; |
| 35 | + |
| 36 | +/** The idle window this instance runs with — small enough that the sweep, not |
| 37 | + * the TTL, sets the pace. */ |
| 38 | +const IDLE_TTL_MS = 2_000; |
| 39 | + |
| 40 | +/** |
| 41 | + * How long to leave the session untouched. The store floors its sweep interval |
| 42 | + * at 30s, so an idle session is disposed at the first tick that falls at least |
| 43 | + * one TTL after its last request. Waiting two full sweep intervals plus the TTL |
| 44 | + * means the assertion can never race a tick that has not fired yet. |
| 45 | + */ |
| 46 | +const QUIET_WINDOW_MS = 2 * 30_000 + IDLE_TTL_MS + 5_000; |
| 47 | + |
| 48 | +interface JsonRpcErrorBody { |
| 49 | + readonly error?: { readonly code?: number; readonly message?: string }; |
| 50 | +} |
| 51 | + |
| 52 | +scenario( |
| 53 | + "MCP · an idle self-host session is evicted and answers 404 -32001 until the client re-initializes", |
| 54 | + // Own vite dev boot (cold on a fresh checkout) plus a 67s quiet window, so |
| 55 | + // this needs materially more than the project's 180s default. |
| 56 | + { timeout: 420_000 }, |
| 57 | + Effect.gen(function* () { |
| 58 | + // Selfhost-shaped scenario: yielded for the target name in failures, and so |
| 59 | + // the file reads like its neighbours. |
| 60 | + yield* Target; |
| 61 | + const runDir = yield* RunDir; |
| 62 | + |
| 63 | + const dataDir = mkdtempSync(join(tmpdir(), "executor-selfhost-idle-ttl-")); |
| 64 | + |
| 65 | + // A distinct env var (not E2E_SELFHOST_PORT, which the shared instance has |
| 66 | + // already published into this worker's env) so the claim actually probes |
| 67 | + // and locks a free port instead of returning the shared one. |
| 68 | + const booted = yield* Effect.promise(() => |
| 69 | + claimAndBoot( |
| 70 | + [{ envVar: "E2E_SELFHOST_IDLE_TTL_PORT", offset: 6, label: "selfhost idle-ttl vite dev" }], |
| 71 | + async (ports) => { |
| 72 | + const port = ports.E2E_SELFHOST_IDLE_TTL_PORT!; |
| 73 | + const baseUrl = `http://localhost:${port}`; |
| 74 | + const procs = await bootSelfhost({ |
| 75 | + port, |
| 76 | + webBaseUrl: baseUrl, |
| 77 | + admin: SELFHOST_ADMIN, |
| 78 | + dataDir, |
| 79 | + logFile: join(runDir, "idle-ttl-boot.log"), |
| 80 | + mcpSessionIdleTtlMs: IDLE_TTL_MS, |
| 81 | + }); |
| 82 | + return { teardown: procs.teardown, value: baseUrl }; |
| 83 | + }, |
| 84 | + { label: "selfhost idle-ttl", retryWhen: isBootReadinessTimeout }, |
| 85 | + ), |
| 86 | + ); |
| 87 | + |
| 88 | + yield* Effect.gen(function* () { |
| 89 | + const baseUrl = booted.value; |
| 90 | + const mcpUrl = new URL("/mcp", baseUrl).toString(); |
| 91 | + const { cookieHeader } = yield* Effect.promise(() => signInSession(baseUrl, SELFHOST_ADMIN)); |
| 92 | + |
| 93 | + const initialize = async (): Promise<Response> => |
| 94 | + fetch(mcpUrl, { |
| 95 | + method: "POST", |
| 96 | + headers: { |
| 97 | + cookie: cookieHeader, |
| 98 | + "content-type": "application/json", |
| 99 | + accept: "application/json, text/event-stream", |
| 100 | + }, |
| 101 | + body: JSON.stringify({ |
| 102 | + jsonrpc: "2.0", |
| 103 | + id: 1, |
| 104 | + method: "initialize", |
| 105 | + params: { |
| 106 | + protocolVersion: "2025-06-18", |
| 107 | + capabilities: {}, |
| 108 | + clientInfo: { name: "idle-ttl-e2e", version: "1" }, |
| 109 | + }, |
| 110 | + }), |
| 111 | + }); |
| 112 | + |
| 113 | + const listTools = async (sessionId: string, id: number): Promise<Response> => |
| 114 | + fetch(mcpUrl, { |
| 115 | + method: "POST", |
| 116 | + headers: { |
| 117 | + cookie: cookieHeader, |
| 118 | + "mcp-session-id": sessionId, |
| 119 | + "mcp-protocol-version": "2025-06-18", |
| 120 | + "content-type": "application/json", |
| 121 | + accept: "application/json, text/event-stream", |
| 122 | + }, |
| 123 | + body: JSON.stringify({ jsonrpc: "2.0", id, method: "tools/list" }), |
| 124 | + }); |
| 125 | + |
| 126 | + // 1. A client initializes and the session serves. |
| 127 | + const opened = yield* Effect.promise(initialize); |
| 128 | + expect(opened.status, "initialize succeeds").toBe(200); |
| 129 | + const sessionId = opened.headers.get("mcp-session-id"); |
| 130 | + expect(sessionId, "initialize returns a session id").toEqual(expect.any(String)); |
| 131 | + |
| 132 | + const working = yield* Effect.promise(() => listTools(sessionId!, 2)); |
| 133 | + expect(working.status, "the fresh session serves a request").toBe(200); |
| 134 | + |
| 135 | + // 2. Idleness, driven by the clock and nothing else. Touching the session |
| 136 | + // here — even to poll — would restamp it and defeat the measurement. |
| 137 | + yield* Effect.sleep(`${QUIET_WINDOW_MS} millis`); |
| 138 | + |
| 139 | + // 3. The evicted id is gone, and says so in the shape a client acts on: |
| 140 | + // 404 tells it the id is dead, -32001 is the session-lifecycle code. |
| 141 | + const afterIdle = yield* Effect.promise(() => listTools(sessionId!, 3)); |
| 142 | + const afterIdleBody = yield* Effect.promise(() => afterIdle.text()); |
| 143 | + // A session that was NOT evicted answers with the whole tool catalog, so |
| 144 | + // the diagnostic is truncated — the status is the assertion, and a full |
| 145 | + // tools/list dump in the failure output helps nobody. |
| 146 | + expect( |
| 147 | + afterIdle.status, |
| 148 | + `an idle session is evicted, so its id 404s; body starts: ${afterIdleBody.slice(0, 200)}`, |
| 149 | + ).toBe(404); |
| 150 | + // oxlint-disable-next-line executor/no-json-parse -- boundary: the raw JSON-RPC error frame this scenario asserts on, never decoded into a domain type |
| 151 | + const parsed = JSON.parse(afterIdleBody) as JsonRpcErrorBody; |
| 152 | + expect(parsed.error?.code, "the 404 carries the session-lifecycle code").toBe(-32001); |
| 153 | + |
| 154 | + // 4. The cue is actionable: re-initializing gets a NEW, working session. |
| 155 | + const reopened = yield* Effect.promise(initialize); |
| 156 | + expect(reopened.status, "the client can re-initialize after eviction").toBe(200); |
| 157 | + const newSessionId = reopened.headers.get("mcp-session-id"); |
| 158 | + expect(newSessionId, "re-initialize returns a session id").toEqual(expect.any(String)); |
| 159 | + expect(newSessionId, "re-initialize issues a different session").not.toBe(sessionId); |
| 160 | + |
| 161 | + const afterReinit = yield* Effect.promise(() => listTools(newSessionId!, 4)); |
| 162 | + expect(afterReinit.status, "the re-initialized session serves a request").toBe(200); |
| 163 | + }).pipe( |
| 164 | + Effect.ensuring( |
| 165 | + Effect.promise(async () => { |
| 166 | + await booted.teardown(); |
| 167 | + rmSync(dataDir, { recursive: true, force: true }); |
| 168 | + }), |
| 169 | + ), |
| 170 | + ); |
| 171 | + }), |
| 172 | +); |
0 commit comments