From 8311e9c4b908f44af8f9552e49912f1e3ad0cbbd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Jul 2026 11:10:18 +0000 Subject: [PATCH] fix(companion): rollback session when POST /sessions fails after create createSession could leave orphaned browser contexts when response validation or serialization failed after the runtime session was created. Co-authored-by: esadrianno --- services/companion/src/server.test.ts | 37 +++++++++++++++++++++++++++ services/companion/src/server.ts | 10 ++++++++ 2 files changed, 47 insertions(+) diff --git a/services/companion/src/server.test.ts b/services/companion/src/server.test.ts index bdce8ad..667baa2 100644 --- a/services/companion/src/server.test.ts +++ b/services/companion/src/server.test.ts @@ -1,6 +1,8 @@ +import { SessionCreatedSchema } from "@webchain/protocol"; import type { BrowserRuntime } from "@webchain/runtime"; import { WebchainRuntimeError } from "@webchain/runtime"; import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; import { createCompanionApp } from "./server.js"; function parseApiError(res: { body: string }) { @@ -388,6 +390,41 @@ describe("createCompanionApp", () => { await app.close(); }); + it("rolls back created session when response validation fails", async () => { + const runtime = mockRuntime({ + createSession: vi.fn(async () => ({ + sessionId: "sid-rollback", + pageId: "pid-1", + createdAt: new Date().toISOString(), + })), + }); + const { app } = await createCompanionApp({ + runtime, + logger: false, + localToken: "tok", + }); + + const parseSpy = vi + .spyOn(SessionCreatedSchema, "parse") + .mockImplementation(() => { + throw new z.ZodError([]); + }); + + const res = await app.inject({ + method: "POST", + url: "/sessions", + headers: { "x-webchain-token": "tok" }, + }); + + parseSpy.mockRestore(); + expect(res.statusCode).toBe(400); + expect(runtime.closeSession).toHaveBeenCalledWith({ + action: "closeSession", + sessionId: "sid-rollback", + }); + await app.close(); + }); + it("returns 503 when runtime reports missing browsers", async () => { const runtime = mockRuntime({ createSession: vi.fn(async () => { diff --git a/services/companion/src/server.ts b/services/companion/src/server.ts index c79ee84..c1d0c15 100644 --- a/services/companion/src/server.ts +++ b/services/companion/src/server.ts @@ -161,8 +161,10 @@ export async function createCompanionApp( app.post("/sessions", async (_request, reply) => { const trace = createTraceContext(); + let createdSessionId: string | undefined; try { const session = await options.runtime.createSession(); + createdSessionId = session.sessionId; const parsedSession = SessionCreatedSchema.parse(session); return SessionCreatedResponseSchema.parse({ ...parsedSession, @@ -173,6 +175,14 @@ export async function createCompanionApp( ), }); } catch (error) { + if (createdSessionId) { + await options.runtime + .closeSession({ + action: "closeSession", + sessionId: createdSessionId, + }) + .catch(() => {}); + } return sendRuntimeFailure(reply, error, trace); } });