Dr. Dominik Jain 14b53ecfec
Bound MCP memory consumption by limiting parallel exports & response size (#9748)
*  Bound the size of plugin task responses

When using the integrated remote MCP server, bound response size.
All responses are passed to LLMs, which themselves impose bounds.
This is a measure to bound memory usage in the centrally provided
MCP server.

GitHub #9493

*  Bound parallelism in ExportShapeTool

Use an integer semaphore to bound parallel requests to this
memory-intensive tool, thus bounding memory usage.

GitHub #9493

*  Add (manual) integration test script for ExportShapeTool parallelism

Add dependency tsx to facilitate executions.

GitHub #9493

*  Make number of parallel export requests configurable in ExportShapeTool

Use env var PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS to configure
the maximum number of requests in multi-user mode (default 0, no limit).
2026-05-19 19:37:29 +02:00

70 lines
2.1 KiB
TypeScript

import { createLogger } from "../logger";
/**
* Counting semaphore for bounding the parallelism of asynchronous operations.
*
* Calls in excess of the configured maximum are queued in FIFO order and
* proceed once an earlier holder has released its permit.
*/
export class Semaphore {
private static readonly logger = createLogger("Semaphore");
private available: number;
private readonly waiters: Array<() => void> = [];
/**
* @param name - identifier used in log messages so the source of contention is recognisable
* @param maxConcurrent - the maximum number of permits that may be held simultaneously
*/
constructor(
private readonly name: string,
maxConcurrent: number
) {
if (maxConcurrent < 1) {
throw new Error(`maxConcurrent must be at least 1; got ${maxConcurrent}`);
}
this.available = maxConcurrent;
}
/**
* Acquires a permit, runs the given function, and releases the permit when it
* settles - including on rejection. Queues if no permit is currently available.
*
* @param fn - the function to run while holding the permit
* @returns the value returned by fn
*/
public async withPermit<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire();
try {
return await fn();
} finally {
this.release();
}
}
private acquire(): Promise<void> {
if (this.available > 0) {
this.available--;
return Promise.resolve();
}
return new Promise<void>((resolve) => {
this.waiters.push(resolve);
Semaphore.logger.info(
"Semaphore '%s' saturated; request queued (%d waiting)",
this.name,
this.waiters.length
);
});
}
private release(): void {
const next = this.waiters.shift();
if (next !== undefined) {
// pass the permit directly to the next waiter without touching `available`
next();
} else {
this.available++;
}
}
}