From ca3ce09baec7290b6ad6c8ef2011e95430ee15cc Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Tue, 29 Sep 2026 18:26:24 +0530 Subject: [PATCH 1/2] fix(perf): bound sharp memory in screenshot processing to prevent pod OOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchAutomationScreenshots compressed up to 5 screenshots concurrently (Promise.all) through sharp, with sharp's default libvips cache and a thread pool sized to CPU cores and no cap on peak buffers. On memory- constrained pods that is an OOM risk (several tens-of-MB pixel buffers decoded at once). - Configure sharp once: cache(false) + concurrency(1) — no libvips cache, single worker thread. - Process screenshots sequentially so peak memory is a single image rather than all of them at once. Co-Authored-By: Claude Opus 4.8 --- src/lib/utils.ts | 7 +++++ src/tools/automate-utils/fetch-screenshots.ts | 31 ++++++++----------- tests/setup.ts | 11 ++++--- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 31af99f8..d6ea4c91 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -12,6 +12,7 @@ export function sanitizeUrlParam(param: string): string { } const ONE_MB = 1048576; +let sharpConfigured = false; //Compresses a base64 image intelligently to keep it under 1 MB if needed. export async function maybeCompressBase64(base64: string): Promise { @@ -27,6 +28,12 @@ export async function maybeCompressBase64(base64: string): Promise { try { const { default: sharp } = await import("sharp"); + if (!sharpConfigured) { + // Bound memory on constrained pods: no libvips cache, single worker thread. + sharp.cache(false); + sharp.concurrency(1); + sharpConfigured = true; + } const compressedBuffer = await sharp(buffer).png({ quality }).toBuffer(); return compressedBuffer.toString("base64"); } catch (err) { diff --git a/src/tools/automate-utils/fetch-screenshots.ts b/src/tools/automate-utils/fetch-screenshots.ts index 46eff517..4f99d774 100644 --- a/src/tools/automate-utils/fetch-screenshots.ts +++ b/src/tools/automate-utils/fetch-screenshots.ts @@ -57,24 +57,19 @@ async function extractScreenshotUrls( async function convertUrlsToBase64( urls: string[], ): Promise> { - const screenshots = await Promise.all( - urls.map(async (url) => { - const response = await apiClient.get({ - url, - responseType: "arraybuffer", - }); - // Axios returns response.data as a Buffer for binary data - const base64 = Buffer.from(response.data).toString("base64"); - - // Compress the base64 image if needed - const compressedBase64 = await maybeCompressBase64(base64); - - return { - url, - base64: compressedBase64, - }; - }), - ); + // Process sequentially so at most one image is decoded/compressed at a time, + // keeping peak memory to a single image rather than all of them at once. + const screenshots: Array<{ url: string; base64: string }> = []; + for (const url of urls) { + const response = await apiClient.get({ + url, + responseType: "arraybuffer", + }); + // Axios returns response.data as a Buffer for binary data + const base64 = Buffer.from(response.data).toString("base64"); + const compressedBase64 = await maybeCompressBase64(base64); + screenshots.push({ url, base64: compressedBase64 }); + } return screenshots; } diff --git a/tests/setup.ts b/tests/setup.ts index b56b9f4a..e634ea58 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,10 +1,13 @@ import { vi } from "vitest"; // Mock sharp globally — it's a native binary module that may not be available in CI -vi.mock("sharp", () => ({ - default: vi.fn().mockReturnValue({ +vi.mock("sharp", () => { + const sharp: any = vi.fn().mockReturnValue({ png: vi.fn().mockReturnValue({ toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-image")), }), - }), -})); + }); + sharp.cache = vi.fn(); + sharp.concurrency = vi.fn(); + return { default: sharp }; +}); From 7abd732003567e4b3ba53b3ebc5032cf69d64818 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Tue, 29 Sep 2026 18:29:36 +0530 Subject: [PATCH 2/2] fix(perf): bound sharp screenshot memory in hosted mode to prevent pod OOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchAutomationScreenshots compressed up to 5 screenshots concurrently (Promise.all) through sharp, with sharp's default libvips cache and a CPU-core-sized thread pool. On memory-constrained hosted pods that peak (several tens-of-MB pixel buffers decoded at once) is an OOM risk. Scoped to hosted (REMOTE_MCP) mode only — local runs are unchanged: - sharp cache(false) + concurrency(1) when REMOTE_MCP. - Screenshots processed sequentially when REMOTE_MCP (peak = one image); local keeps the concurrent path. Co-Authored-By: Claude Opus 4.8 --- src/lib/utils.ts | 5 +-- src/tools/automate-utils/fetch-screenshots.ts | 32 +++++++++++-------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d6ea4c91..e866f2e4 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,6 +5,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { trackMCP } from "../index.js"; import logger from "../logger.js"; +import globalConfig from "../config.js"; export function sanitizeUrlParam(param: string): string { // Remove any characters that could be used for command injection @@ -28,8 +29,8 @@ export async function maybeCompressBase64(base64: string): Promise { try { const { default: sharp } = await import("sharp"); - if (!sharpConfigured) { - // Bound memory on constrained pods: no libvips cache, single worker thread. + if (!sharpConfigured && globalConfig.REMOTE_MCP) { + // Bound memory on constrained hosted pods: no libvips cache, single worker thread. sharp.cache(false); sharp.concurrency(1); sharpConfigured = true; diff --git a/src/tools/automate-utils/fetch-screenshots.ts b/src/tools/automate-utils/fetch-screenshots.ts index 4f99d774..a570b6f2 100644 --- a/src/tools/automate-utils/fetch-screenshots.ts +++ b/src/tools/automate-utils/fetch-screenshots.ts @@ -3,6 +3,7 @@ import { SessionType } from "../../lib/constants.js"; import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { apiClient } from "../../lib/apiClient.js"; +import globalConfig from "../../config.js"; async function extractScreenshotUrls( sessionId: string, @@ -53,25 +54,30 @@ async function extractScreenshotUrls( return urls; } +async function fetchAndCompress( + url: string, +): Promise<{ url: string; base64: string }> { + const response = await apiClient.get({ url, responseType: "arraybuffer" }); + // Axios returns response.data as a Buffer for binary data + const base64 = Buffer.from(response.data).toString("base64"); + return { url, base64: await maybeCompressBase64(base64) }; +} + //Converts screenshot URLs to base64 encoded images async function convertUrlsToBase64( urls: string[], ): Promise> { - // Process sequentially so at most one image is decoded/compressed at a time, - // keeping peak memory to a single image rather than all of them at once. - const screenshots: Array<{ url: string; base64: string }> = []; - for (const url of urls) { - const response = await apiClient.get({ - url, - responseType: "arraybuffer", - }); - // Axios returns response.data as a Buffer for binary data - const base64 = Buffer.from(response.data).toString("base64"); - const compressedBase64 = await maybeCompressBase64(base64); - screenshots.push({ url, base64: compressedBase64 }); + // Hosted pods are memory-constrained: process one image at a time. Local runs + // keep the concurrent path. + if (globalConfig.REMOTE_MCP) { + const screenshots: Array<{ url: string; base64: string }> = []; + for (const url of urls) { + screenshots.push(await fetchAndCompress(url)); + } + return screenshots; } - return screenshots; + return Promise.all(urls.map(fetchAndCompress)); } //Fetches and converts screenshot URLs to base64 encoded images