feat: add SandboxManager for per-session container lifecycle
This commit is contained in:
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { SandboxManager } from './manager.js';
|
||||||
|
import { DockerSandbox } from './docker.js';
|
||||||
|
import type { SandboxConfig } from '../config/schema.js';
|
||||||
|
|
||||||
|
// Mock DockerSandbox
|
||||||
|
vi.mock('./docker.js', () => ({
|
||||||
|
DockerSandbox: vi.fn().mockImplementation(() => ({
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
destroy: vi.fn().mockResolvedValue(undefined),
|
||||||
|
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||||
|
containerId: 'mock-container',
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SandboxManager', () => {
|
||||||
|
const defaultConfig: SandboxConfig = {
|
||||||
|
enabled: true,
|
||||||
|
image: 'node:22-slim',
|
||||||
|
workspace_dir: '/workspace',
|
||||||
|
network: 'none',
|
||||||
|
memory_limit: '512m',
|
||||||
|
cpu_limit: '1.0',
|
||||||
|
timeout_seconds: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOrCreate()', () => {
|
||||||
|
it('creates a new sandbox for unknown session', async () => {
|
||||||
|
const manager = new SandboxManager(defaultConfig);
|
||||||
|
const sandbox = await manager.getOrCreate('session-1');
|
||||||
|
|
||||||
|
expect(DockerSandbox).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
image: 'node:22-slim',
|
||||||
|
}));
|
||||||
|
expect(sandbox.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses existing sandbox for same session', async () => {
|
||||||
|
const manager = new SandboxManager(defaultConfig);
|
||||||
|
const first = await manager.getOrCreate('session-1');
|
||||||
|
const second = await manager.getOrCreate('session-1');
|
||||||
|
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(DockerSandbox).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates separate sandboxes for different sessions', async () => {
|
||||||
|
const manager = new SandboxManager(defaultConfig);
|
||||||
|
await manager.getOrCreate('session-1');
|
||||||
|
await manager.getOrCreate('session-2');
|
||||||
|
|
||||||
|
expect(DockerSandbox).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destroy()', () => {
|
||||||
|
it('destroys sandbox and removes from cache', async () => {
|
||||||
|
const manager = new SandboxManager(defaultConfig);
|
||||||
|
const sandbox = await manager.getOrCreate('session-1');
|
||||||
|
|
||||||
|
await manager.destroy('session-1');
|
||||||
|
expect(sandbox.destroy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Should create a new one now
|
||||||
|
await manager.getOrCreate('session-1');
|
||||||
|
expect(DockerSandbox).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for unknown session', async () => {
|
||||||
|
const manager = new SandboxManager(defaultConfig);
|
||||||
|
await manager.destroy('nonexistent'); // should not throw
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destroyAll()', () => {
|
||||||
|
it('destroys all sandboxes', async () => {
|
||||||
|
const manager = new SandboxManager(defaultConfig);
|
||||||
|
const s1 = await manager.getOrCreate('session-1');
|
||||||
|
const s2 = await manager.getOrCreate('session-2');
|
||||||
|
|
||||||
|
await manager.destroyAll();
|
||||||
|
expect(s1.destroy).toHaveBeenCalled();
|
||||||
|
expect(s2.destroy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { DockerSandbox } from './docker.js';
|
||||||
|
import type { SandboxConfig } from '../config/schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages per-session Docker sandboxes.
|
||||||
|
* Creates containers lazily on first access, destroys on session cleanup.
|
||||||
|
*/
|
||||||
|
export class SandboxManager {
|
||||||
|
private sandboxes = new Map<string, DockerSandbox>();
|
||||||
|
private config: SandboxConfig;
|
||||||
|
|
||||||
|
constructor(config: SandboxConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get or create a sandbox for a session. */
|
||||||
|
async getOrCreate(sessionId: string): Promise<DockerSandbox> {
|
||||||
|
let sandbox = this.sandboxes.get(sessionId);
|
||||||
|
if (sandbox) return sandbox;
|
||||||
|
|
||||||
|
sandbox = new DockerSandbox({
|
||||||
|
sessionId,
|
||||||
|
image: this.config.image,
|
||||||
|
workspaceDir: this.config.workspace_dir,
|
||||||
|
network: this.config.network,
|
||||||
|
memoryLimit: this.config.memory_limit,
|
||||||
|
cpuLimit: this.config.cpu_limit,
|
||||||
|
timeoutSeconds: this.config.timeout_seconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sandbox.create();
|
||||||
|
this.sandboxes.set(sessionId, sandbox);
|
||||||
|
return sandbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destroy a specific session's sandbox. */
|
||||||
|
async destroy(sessionId: string): Promise<void> {
|
||||||
|
const sandbox = this.sandboxes.get(sessionId);
|
||||||
|
if (!sandbox) return;
|
||||||
|
|
||||||
|
await sandbox.destroy();
|
||||||
|
this.sandboxes.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destroy all sandboxes (daemon shutdown). */
|
||||||
|
async destroyAll(): Promise<void> {
|
||||||
|
const entries = Array.from(this.sandboxes.entries());
|
||||||
|
await Promise.allSettled(
|
||||||
|
entries.map(async ([_id, sandbox]) => {
|
||||||
|
await sandbox.destroy();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.sandboxes.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user