History

Add failing e2e: paused approval must survive the session runtime idle teardown

Reproduces the reported bug: with model elicitation over streamable HTTP, execute returns a paused executionId, but the session DO's 5-minute idle alarm calls closeRuntime() and rebuilds the engine from storage on the next request — the in-memory pause map (fiber + Deferred) is gone, so resume answers 'No paused execution: exec_1'. A 20-second gap passes (the runtime is still warm); the 6-minute gap fails deterministically, so the loss is the app's own idle teardown, not a workerd eviction race.

Rhys Sullivan committed 5 months ago

Commit f3ed1ab1 parent 018e4961 file · +68 −0Browse files

Unified · Split

e2e/cloud/mcp-client-sessions.test.ts+68−0
@@ -213,6 +213,74 @@ const result = await tools.executor.coreTools.policies.list({}); · 212 unmodified lines
213213 return JSON.stringify(result);
214214 `;
215215
216+const executionIdOf = (text: string): string | undefined =>
217+ /\bexecutionId:\s*(\S+)/.exec(text)?.[1];
218+
219+// The session DO tears its runtime down after 5 minutes without a request
220+// (SESSION_TIMEOUT_MS in McpSessionDOBase) and rebuilds it from storage on
221+// the next one — the same engine-state wipe a workerd eviction or a deploy
222+// causes. A user who takes a few minutes to answer an approval crosses this
223+// gap on every paused execution.
224+const IDLE_TEARDOWN_GAP = "6 minutes";
225+
226+scenario(
227+ "MCP sessions · a paused approval survives the session runtime idle teardown and resumes",
228+ { timeout: 480_000 },
229+ Effect.gen(function* () {
230+ const target = yield* Target;
231+ const { client } = yield* Api;
232+ const mcp = yield* Mcp;
233+ const identity = yield* target.newIdentity();
234+ const api = yield* client(coreApi, identity);
235+ const bearer = yield* mcp.mintBearer(emailOf(identity));
236+
237+ const policy = yield* api.policies.create({
238+ payload: { owner: "org", pattern: APPROVAL_TARGET_TOOL, action: "require_approval" },
239+ });
240+
241+ yield* Effect.gen(function* () {
242+ const first = yield* Effect.promise(() => connectClient(target.mcpUrl, bearer));
243+ const sessionId = first.transport.sessionId;
244+ const paused = yield* Effect.promise(() =>
245+ first.client.callTool({ name: "execute", arguments: { code: GATED_CODE } }),
246+ ).pipe(Effect.ensuring(closeQuietly(first)));
247+ const pausedText = textOf(paused);
248+ expect(pausedText, "the gated call pauses instead of completing").toContain(
249+ "Execution paused",
250+ );
251+ const executionId = executionIdOf(pausedText);
252+ expect(executionId, "the paused result carries the executionId").toEqual(expect.any(String));
253+
254+ // The user thinks the approval over. No client is connected and no
255+ // request is in flight, so the session DO instance gets evicted and the
256+ // next request cold-restores it from storage.
257+ yield* Effect.sleep(IDLE_TEARDOWN_GAP);
258+
259+ const second = yield* Effect.promise(() => connectClient(target.mcpUrl, bearer, sessionId));
260+ const resumed = yield* Effect.promise(() =>
261+ second.client.callTool({
262+ name: "resume",
263+ arguments: { executionId: executionId ?? "", action: "accept", content: "{}" },
264+ }),
265+ ).pipe(Effect.ensuring(closeQuietly(second)));
266+ expect(
267+ textOf(resumed),
268+ "the paused execution is still resumable after the eviction gap",
269+ ).not.toContain("No paused execution");
270+ expect(resumed.isError, "the resumed execution completes").not.toBe(true);
271+ expect(textOf(resumed), "the gated tool's result comes back after approval").toContain(
272+ "connections",
273+ );
274+ }).pipe(
275+ Effect.ensuring(
276+ api.policies
277+ .remove({ params: { policyId: policy.id }, payload: { owner: "org" } })
278+ .pipe(Effect.ignore),
279+ ),
280+ );
281+ }),
282+);
283+
216284 scenario(
217285 "MCP sessions · a paused approval survives the client reconnecting and resumes",
218286 {},