import { SANDBOX_CODES } from "../error-codes/error-codes.js"; import { recordPoolAcquisition } from "../telemetry/telemetry.js"; import type { Clock, ExecutionContext, SandboxEngine, SandboxEngineConfig, SandboxOptions, SandboxResult, } from "../types/sandbox.types.js"; /** Factory that builds engines on demand. */ export type EngineFactory = (config: SandboxEngineConfig) => SandboxEngine; /** Pool configuration. */ export interface SandboxPoolConfig { readonly minSize: number; readonly maxSize: number; readonly engineConfig: SandboxEngineConfig; readonly factory: EngineFactory; readonly idleTimeoutMs: number; readonly maxExecutionsPerEngine: number; readonly clock?: Clock; } interface PooledEngine { engine: SandboxEngine; inUse: boolean; executionCount: number; lastUsed: number; createdAt: number; } const DEFAULTS: Omit = { minSize: 2, maxSize: 10, idleTimeoutMs: 60_000, maxExecutionsPerEngine: 1000, }; /** * Pool of pre-warmed sandbox engines. The pool grows lazily up to `maxSize`, * recycles engines past `maxExecutionsPerEngine`, and reclaims idle engines * above `minSize` whose `lastUsed` exceeds `idleTimeoutMs` (relative to the * injectable {@link Clock}, defaulting to `Date.now`). */ export class SandboxPool { private readonly config: SandboxPoolConfig; private readonly clock: Clock; private pool: PooledEngine[] = []; public constructor( config: Pick & Partial ) { this.config = { ...DEFAULTS, ...config }; this.clock = this.config.clock ?? (() => Date.now()); this.warmUp(); } /** Execute code on a pooled engine. */ public async execute( code: string, context?: ExecutionContext, options?: SandboxOptions ): Promise> { const pooled = this.acquire(); if (pooled === null) { recordPoolAcquisition("exhausted"); return { success: false, result: undefined, error: `Sandbox pool exhausted (${SANDBOX_CODES.POOL_EXHAUSTED})`, duration: 0, memoryUsed: 0, logs: [], }; } try { const result = await pooled.engine.execute( code, context, options ); pooled.executionCount += 1; pooled.lastUsed = this.clock(); return result; } finally { this.release(pooled); } } /** Current pool size. */ public size(): number { return this.pool.length; } /** Idle engines. */ public available(): number { return this.pool.filter((p) => !p.inUse).length; } /** In-use engines. */ public inUse(): number { return this.pool.filter((p) => p.inUse).length; } /** Aggregate pool stats. */ public stats(): { size: number; available: number; inUse: number; totalExecutions: number; } { let totalExecutions = 0; for (const p of this.pool) totalExecutions += p.executionCount; return { size: this.pool.length, available: this.available(), inUse: this.inUse(), totalExecutions, }; } /** Bring the pool up to `minSize`. */ public warmUp(): void { while (this.pool.length < this.config.minSize) { this.pool.push(this.createPooled()); } } /** Single cleanup tick — reclaims idle engines above `minSize`. */ public cleanupTick(): void { const now = this.clock(); const survivors: PooledEngine[] = []; let removed = 0; for (const pooled of this.pool) { if ( !pooled.inUse && this.pool.length - removed > this.config.minSize && now - pooled.lastUsed > this.config.idleTimeoutMs ) { pooled.engine.destroy(); removed += 1; continue; } survivors.push(pooled); } this.pool = survivors; } /** Destroy every engine in the pool. */ public destroy(): void { for (const pooled of this.pool) { pooled.engine.destroy(); } this.pool = []; } private acquire(): PooledEngine | null { const idle = this.pool.find((p) => !p.inUse); if (idle !== undefined) { idle.inUse = true; recordPoolAcquisition("acquired"); return idle; } if (this.pool.length < this.config.maxSize) { const pooled = this.createPooled(); pooled.inUse = true; this.pool.push(pooled); recordPoolAcquisition("created"); return pooled; } return null; } private release(pooled: PooledEngine): void { pooled.inUse = false; if (pooled.executionCount >= this.config.maxExecutionsPerEngine) { this.recycle(pooled); } } private recycle(pooled: PooledEngine): void { pooled.engine.destroy(); // Replace in-place: copy a fresh engine onto the same PooledEngine // wrapper so the array reference stays valid for any other code // that holds it. We rotate every field so the recycled entry is // indistinguishable from a freshly created one. const fresh = this.createPooled(); pooled.engine = fresh.engine; pooled.executionCount = 0; pooled.lastUsed = fresh.lastUsed; pooled.createdAt = fresh.createdAt; } private createPooled(): PooledEngine { const engine = this.config.factory(this.config.engineConfig); return { engine, inUse: false, executionCount: 0, lastUsed: this.clock(), createdAt: this.clock(), }; } } export { DEFAULTS as DEFAULT_POOL_CONFIG };